diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4c7584280..f8caa0fd7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,7 +83,7 @@ jobs: publish_docs: needs: update_docs runs-on: ubuntu-latest - if: github.event_name == 'push' && (github.ref == 'refs/heads/dev') + if: github.event_name == 'push' && (github.ref == 'refs/heads/stable') steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 diff --git a/README.md b/README.md index b190acc39..7e7c31174 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,287 @@ [![bbot_banner](https://user-images.githubusercontent.com/20261699/158000235-6c1ace81-a267-4f8e-90a1-f4c16884ebac.png)](https://github.com/blacklanternsecurity/bbot) -# BEE·bot +[![Python Version](https://img.shields.io/badge/python-3.9+-FF8400)](https://www.python.org) [![License](https://img.shields.io/badge/license-GPLv3-FF8400.svg)](https://github.com/blacklanternsecurity/bbot/blob/dev/LICENSE) [![DEF CON Demo Labs 2023](https://img.shields.io/badge/DEF%20CON%20Demo%20Labs-2023-FF8400.svg)](https://forum.defcon.org/node/246338) [![PyPi Downloads](https://static.pepy.tech/personalized-badge/bbot?right_color=orange&left_color=grey)](https://pepy.tech/project/bbot) [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Tests](https://github.com/blacklanternsecurity/bbot/actions/workflows/tests.yml/badge.svg?branch=stable)](https://github.com/blacklanternsecurity/bbot/actions?query=workflow%3A"tests") [![Codecov](https://codecov.io/gh/blacklanternsecurity/bbot/branch/dev/graph/badge.svg?token=IR5AZBDM5K)](https://codecov.io/gh/blacklanternsecurity/bbot) [![Discord](https://img.shields.io/discord/859164869970362439)](https://discord.com/invite/PZqkgxu5SA) -### A Recursive Internet Scanner for Hackers. +### **BEE·bot** is a multipurpose scanner inspired by [Spiderfoot](https://github.com/smicallef/spiderfoot), built to automate your **Recon**, **Bug Bounties**, and **ASM**! -[![Python Version](https://img.shields.io/badge/python-3.9+-FF8400)](https://www.python.org) [![License](https://img.shields.io/badge/license-GPLv3-FF8400.svg)](https://github.com/blacklanternsecurity/bbot/blob/dev/LICENSE) [![DEF CON Demo Labs 2023](https://img.shields.io/badge/DEF%20CON%20Demo%20Labs-2023-FF8400.svg)](https://forum.defcon.org/node/246338) [![PyPi Downloads](https://static.pepy.tech/personalized-badge/bbot?right_color=orange&left_color=grey)](https://pepy.tech/project/bbot) [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Tests](https://github.com/blacklanternsecurity/bbot/actions/workflows/tests.yml/badge.svg?branch=stable)](https://github.com/blacklanternsecurity/bbot/actions?query=workflow%3A"tests") [![Codecov](https://codecov.io/gh/blacklanternsecurity/bbot/branch/dev/graph/badge.svg?token=IR5AZBDM5K)](https://codecov.io/gh/blacklanternsecurity/bbot) [![Discord](https://img.shields.io/discord/859164869970362439)](https://discord.com/invite/PZqkgxu5SA) +https://github.com/blacklanternsecurity/bbot/assets/20261699/e539e89b-92ea-46fa-b893-9cde94eebf81 -BBOT (Bighuge BLS OSINT Tool) is a recursive internet scanner inspired by [Spiderfoot](https://github.com/smicallef/spiderfoot), but designed to be faster, more reliable, and friendlier to pentesters, bug bounty hunters, and developers. +_A BBOT scan in real-time - visualization with [VivaGraphJS](https://github.com/blacklanternsecurity/bbot-vivagraphjs)_ -Special features include: +## Installation -- Support for Multiple Targets -- Web Screenshots -- Suite of Offensive Web Modules -- AI-powered Subdomain Mutations -- Native Output to Neo4j (and more) -- Python API + Developer [Documentation](https://www.blacklanternsecurity.com/bbot/) +```bash +# stable version +pipx install bbot -https://github.com/blacklanternsecurity/bbot/assets/20261699/742df3fe-5d1f-4aea-83f6-f990657bf695 +# bleeding edge (dev branch) +pipx install --pip-args '\--pre' bbot +``` -_A BBOT scan in real-time - visualization with [VivaGraphJS](https://github.com/blacklanternsecurity/bbot-vivagraphjs)_ +_For more installation methods, including [Docker](https://hub.docker.com/r/blacklanternsecurity/bbot), see [Getting Started](https://www.blacklanternsecurity.com/bbot/)_ -## Quick Start Guide +## Example Commands + +### 1) Subdomain Finder + +Passive API sources plus a recursive DNS brute-force with target-specific subdomain mutations. + +```bash +# find subdomains of evilcorp.com +bbot -t evilcorp.com -p subdomain-enum +``` -Below are some short help sections to get you up and running. +
-Installation ( Pip ) +subdomain-enum.yml + +```yaml +description: Enumerate subdomains via APIs, brute-force + +flags: + # enable every module with the subdomain-enum flag + - subdomain-enum + +output_modules: + # output unique subdomains to TXT file + - subdomains + +config: + dns: + threads: 25 + brute_threads: 1000 + # put your API keys here + modules: + github: + api_key: "" + chaos: + api_key: "" + securitytrails: + api_key: "" + +``` + +
+ + + +BBOT consistently finds 20-50% more subdomains than other tools. The bigger the domain, the bigger the difference. To learn how this is possible, see [How It Works](https://www.blacklanternsecurity.com/bbot/how_it_works/). -Note: BBOT's [PyPi package](https://pypi.org/project/bbot/) requires Linux and Python 3.9+. +![subdomain-stats-ebay](https://github.com/blacklanternsecurity/bbot/assets/20261699/de3e7f21-6f52-4ac4-8eab-367296cd385f) + +### 2) Web Spider ```bash -# stable version -pipx install bbot +# crawl evilcorp.com, extracting emails and other goodies +bbot -t evilcorp.com -p spider +``` -# bleeding edge (dev branch) -pipx install --pip-args '\--pre' bbot + + +
+spider.yml + +```yaml +description: Recursive web spider + +modules: + - httpx + +config: + web: + # how many links to follow in a row + spider_distance: 2 + # don't follow links whose directory depth is higher than 4 + spider_depth: 4 + # maximum number of links to follow per page + spider_links_per_page: 25 -bbot --help ```
-
-Installation ( Docker ) + -[Docker images](https://hub.docker.com/r/blacklanternsecurity/bbot) are provided, along with helper script `bbot-docker.sh` to persist your scan data. +### 3) Email Gatherer ```bash -# bleeding edge (dev) -docker run -it blacklanternsecurity/bbot --help +# quick email enum with free APIs + scraping +bbot -t evilcorp.com -p email-enum -# stable -docker run -it blacklanternsecurity/bbot:stable --help +# pair with subdomain enum + web spider for maximum yield +bbot -t evilcorp.com -p email-enum subdomain-enum spider +``` + + + +
+email-enum.yml + +```yaml +description: Enumerate email addresses from APIs, web crawling, etc. + +flags: + - email-enum + +output_modules: + - emails -# helper script -git clone https://github.com/blacklanternsecurity/bbot && cd bbot -./bbot-docker.sh --help ```
+ + +### 4) Web Scanner + +```bash +# run a light web scan against www.evilcorp.com +bbot -t www.evilcorp.com -p web-basic + +# run a heavy web scan against www.evilcorp.com +bbot -t www.evilcorp.com -p web-thorough +``` + + +
-Example Usage +web-basic.yml -## Example Commands +```yaml +description: Quick web scan -Scan output, logs, etc. are saved to `~/.bbot`. For more detailed examples and explanations, see [Scanning](https://www.blacklanternsecurity.com/bbot/scanning). +include: + - iis-shortnames - -**Subdomains:** +flags: + - web-basic -```bash -# Perform a full subdomain enumeration on evilcorp.com -bbot -t evilcorp.com -f subdomain-enum ``` -**Subdomains (passive only):** +
+ + + + + +
+web-thorough.yml + +```yaml +description: Aggressive web scan + +include: + # include the web-basic preset + - web-basic + +flags: + - web-thorough -```bash -# Perform a passive-only subdomain enumeration on evilcorp.com -bbot -t evilcorp.com -f subdomain-enum -rf passive ``` -**Subdomains + port scan + web screenshots:** +
+ + + +### 5) Everything Everywhere All at Once ```bash -# Port-scan every subdomain, screenshot every webpage, output to current directory -bbot -t evilcorp.com -f subdomain-enum -m nmap gowitness -n my_scan -o . +# everything everywhere all at once +bbot -t evilcorp.com -p kitchen-sink + +# roughly equivalent to: +bbot -t evilcorp.com -p subdomain-enum cloud-enum code-enum email-enum spider web-basic paramminer dirbust-light web-screenshots ``` -**Subdomains + basic web scan:** + + +
+kitchen-sink.yml + +```yaml +description: Everything everywhere all at once + +include: + - subdomain-enum + - cloud-enum + - code-enum + - email-enum + - spider + - web-basic + - paramminer + - dirbust-light + - web-screenshots + +config: + modules: + baddns: + enable_references: True + + -```bash -# A basic web scan includes wappalyzer, robots.txt, and other non-intrusive web modules -bbot -t evilcorp.com -f subdomain-enum web-basic ``` -**Web spider:** +
-```bash -# Crawl www.evilcorp.com up to a max depth of 2, automatically extracting emails, secrets, etc. -bbot -t www.evilcorp.com -m httpx robots badsecrets secretsdb -c web_spider_distance=2 web_spider_depth=2 + + +## How it Works + +Click the graph below to explore the [inner workings](https://www.blacklanternsecurity.com/bbot/how_it_works/) of BBOT. + +[![image](https://github.com/blacklanternsecurity/bbot/assets/20261699/e55ba6bd-6d97-48a6-96f0-e122acc23513)](https://www.blacklanternsecurity.com/bbot/how_it_works/) + +## BBOT as a Python Library + +#### Synchronous +```python +from bbot.scanner import Scanner + +scan = Scanner("evilcorp.com", presets=["subdomain-enum"]) +for event in scan.start(): + print(event) ``` -**Everything everywhere all at once:** +#### Asynchronous +```python +from bbot.scanner import Scanner -```bash -# Subdomains, emails, cloud buckets, port scan, basic web, web screenshots, nuclei -bbot -t evilcorp.com -f subdomain-enum email-enum cloud-enum web-basic -m nmap gowitness nuclei --allow-deadly +async def main(): + scan = Scanner("evilcorp.com", presets=["subdomain-enum"]) + async for event in scan.async_start(): + print(event.json()) + +import asyncio +asyncio.run(main()) ``` - + +
+SEE: This Nefarious Discord Bot + +A [BBOT Discord Bot](https://www.blacklanternsecurity.com/bbot/dev/discord_bot/) that responds to the `/scan` command. Scan the internet from the comfort of your discord server! + +![bbot-discord](https://github.com/blacklanternsecurity/bbot/assets/20261699/22b268a2-0dfd-4c2a-b7c5-548c0f2cc6f9) + +
+ +## Feature Overview + +- Support for Multiple Targets +- Web Screenshots +- Suite of Offensive Web Modules +- NLP-powered Subdomain Mutations +- Native Output to Neo4j (and more) +- Automatic dependency install with Ansible +- Search entire attack surface with custom YARA rules +- Python API + Developer Documentation ## Targets BBOT accepts an unlimited number of targets via `-t`. You can specify targets either directly on the command line or in files (or both!): ```bash -bbot -t evilcorp.com evilcorp.org 1.2.3.0/24 -f subdomain-enum +bbot -t evilcorp.com evilcorp.org 1.2.3.0/24 -p subdomain-enum ``` Targets can be any of the following: @@ -134,7 +298,7 @@ For more information, see [Targets](https://www.blacklanternsecurity.com/bbot/sc Similar to Amass or Subfinder, BBOT supports API keys for various third-party services such as SecurityTrails, etc. -The standard way to do this is to enter your API keys in **`~/.config/bbot/secrets.yml`**: +The standard way to do this is to enter your API keys in **`~/.config/bbot/bbot.yml`**: ```yaml modules: shodan_dns: @@ -152,43 +316,17 @@ If you like, you can also specify them on the command line: bbot -c modules.virustotal.api_key=dd5f0eee2e4a99b71a939bded450b246 ``` -For details, see [Configuration](https://www.blacklanternsecurity.com/bbot/scanning/configuration/) - -## BBOT as a Python Library - -BBOT exposes a Python API that allows it to be used for all kinds of fun and nefarious purposes, like a [Discord Bot](https://www.blacklanternsecurity.com/bbot/dev/#bbot-python-library-advanced-usage#discord-bot-example) that responds to the `/scan` command. - -![bbot-discord](https://github.com/blacklanternsecurity/bbot/assets/20261699/22b268a2-0dfd-4c2a-b7c5-548c0f2cc6f9) - -**Synchronous** - -```python -from bbot.scanner import Scanner - -# any number of targets can be specified -scan = Scanner("example.com", "scanme.nmap.org", modules=["nmap", "sslcert"]) -for event in scan.start(): - print(event.json()) -``` - -**Asynchronous** - -```python -from bbot.scanner import Scanner - -async def main(): - scan = Scanner("example.com", "scanme.nmap.org", modules=["nmap", "sslcert"]) - async for event in scan.async_start(): - print(event.json()) +For details, see [Configuration](https://www.blacklanternsecurity.com/bbot/scanning/configuration/). -import asyncio -asyncio.run(main()) -``` +## Complete Lists of Modules, Flags, etc. -
+- Complete list of [Modules](https://www.blacklanternsecurity.com/bbot/modules/list_of_modules/). +- Complete list of [Flags](https://www.blacklanternsecurity.com/bbot/scanning/#list-of-flags). +- Complete list of [Presets](https://www.blacklanternsecurity.com/bbot/scanning/presets_list/). + - Complete list of [Global Config Options](https://www.blacklanternsecurity.com/bbot/scanning/configuration/#global-config-options). + - Complete list of [Module Config Options](https://www.blacklanternsecurity.com/bbot/scanning/configuration/#module-config-options). -
-Documentation - Table of Contents +## Documentation - **User Manual** @@ -198,6 +336,9 @@ asyncio.run(main()) - [Comparison to Other Tools](https://www.blacklanternsecurity.com/bbot/comparison) - **Scanning** - [Scanning Overview](https://www.blacklanternsecurity.com/bbot/scanning/) + - **Presets** + - [Overview](https://www.blacklanternsecurity.com/bbot/scanning/presets) + - [List of Presets](https://www.blacklanternsecurity.com/bbot/scanning/presets_list) - [Events](https://www.blacklanternsecurity.com/bbot/scanning/events) - [Output](https://www.blacklanternsecurity.com/bbot/scanning/output) - [Tips and Tricks](https://www.blacklanternsecurity.com/bbot/scanning/tips_and_tricks) @@ -207,31 +348,36 @@ asyncio.run(main()) - [List of Modules](https://www.blacklanternsecurity.com/bbot/modules/list_of_modules) - [Nuclei](https://www.blacklanternsecurity.com/bbot/modules/nuclei) - **Misc** + - [Contribution](https://www.blacklanternsecurity.com/bbot/contribution) - [Release History](https://www.blacklanternsecurity.com/bbot/release_history) - [Troubleshooting](https://www.blacklanternsecurity.com/bbot/troubleshooting) - **Developer Manual** - - [How to Write a Module](https://www.blacklanternsecurity.com/bbot/contribution) - [Development Overview](https://www.blacklanternsecurity.com/bbot/dev/) - - [Scanner](https://www.blacklanternsecurity.com/bbot/dev/scanner) - - [Event](https://www.blacklanternsecurity.com/bbot/dev/event) - - [Target](https://www.blacklanternsecurity.com/bbot/dev/target) - - [BaseModule](https://www.blacklanternsecurity.com/bbot/dev/basemodule) - - **Helpers** - - [Overview](https://www.blacklanternsecurity.com/bbot/dev/helpers/) - - [Command](https://www.blacklanternsecurity.com/bbot/dev/helpers/command) - - [DNS](https://www.blacklanternsecurity.com/bbot/dev/helpers/dns) - - [Interactsh](https://www.blacklanternsecurity.com/bbot/dev/helpers/interactsh) - - [Miscellaneous](https://www.blacklanternsecurity.com/bbot/dev/helpers/misc) - - [Web](https://www.blacklanternsecurity.com/bbot/dev/helpers/web) - - [Word Cloud](https://www.blacklanternsecurity.com/bbot/dev/helpers/wordcloud) + - [BBOT Internal Architecture](https://www.blacklanternsecurity.com/bbot/dev/architecture) + - [How to Write a BBOT Module](https://www.blacklanternsecurity.com/bbot/dev/module_howto) + - [Unit Tests](https://www.blacklanternsecurity.com/bbot/dev/tests) + - [Discord Bot Example](https://www.blacklanternsecurity.com/bbot/dev/discord_bot) + - **Code Reference** + - [Scanner](https://www.blacklanternsecurity.com/bbot/dev/scanner) + - [Presets](https://www.blacklanternsecurity.com/bbot/dev/presets) + - [Event](https://www.blacklanternsecurity.com/bbot/dev/event) + - [Target](https://www.blacklanternsecurity.com/bbot/dev/target) + - [BaseModule](https://www.blacklanternsecurity.com/bbot/dev/basemodule) + - [BBOTCore](https://www.blacklanternsecurity.com/bbot/dev/core) + - [Engine](https://www.blacklanternsecurity.com/bbot/dev/engine) + - **Helpers** + - [Overview](https://www.blacklanternsecurity.com/bbot/dev/helpers/) + - [Command](https://www.blacklanternsecurity.com/bbot/dev/helpers/command) + - [DNS](https://www.blacklanternsecurity.com/bbot/dev/helpers/dns) + - [Interactsh](https://www.blacklanternsecurity.com/bbot/dev/helpers/interactsh) + - [Miscellaneous](https://www.blacklanternsecurity.com/bbot/dev/helpers/misc) + - [Web](https://www.blacklanternsecurity.com/bbot/dev/helpers/web) + - [Word Cloud](https://www.blacklanternsecurity.com/bbot/dev/helpers/wordcloud) -
- -
-Contribution +## Contribution -BBOT is constantly being improved by the community. Every day it grows more powerful! +Some of the best BBOT modules were written by the community. BBOT is being constantly improved; every day it grows more powerful! We welcome contributions. Not just code, but ideas too! If you have an idea for a new feature, please let us know in [Discussions](https://github.com/blacklanternsecurity/bbot/discussions). If you want to get your hands dirty, see [Contribution](https://www.blacklanternsecurity.com/bbot/contribution/). There you can find setup instructions and a simple tutorial on how to write a BBOT module. We also have extensive [Developer Documentation](https://www.blacklanternsecurity.com/bbot/dev/). @@ -243,72 +389,12 @@ Thanks to these amazing people for contributing to BBOT! :heart:

-Special thanks to the following people who made BBOT possible: +Special thanks to: - @TheTechromancer for creating [BBOT](https://github.com/blacklanternsecurity/bbot) -- @liquidsec for his extensive work on BBOT's web hacking features, including [badsecrets](https://github.com/blacklanternsecurity/badsecrets) +- @liquidsec for his extensive work on BBOT's web hacking features, including [badsecrets](https://github.com/blacklanternsecurity/badsecrets) and [baddns](https://github.com/blacklanternsecurity/baddns) - Steve Micallef (@smicallef) for creating Spiderfoot - @kerrymilan for his Neo4j and Ansible expertise +- @domwhewell-sage for his family of badass code-looting modules - @aconite33 and @amiremami for their ruthless testing -- Aleksei Kornev (@alekseiko) for allowing us ownership of the bbot Pypi repository <3 - -
- -## Comparison to Other Tools - -BBOT consistently finds 20-50% more subdomains than other tools. The bigger the domain, the bigger the difference. To learn how this is possible, see [How It Works](https://www.blacklanternsecurity.com/bbot/how_it_works/). - -![subdomain-stats-ebay](https://github.com/blacklanternsecurity/bbot/assets/20261699/53e07e9f-50b6-4b70-9e83-297dbfbcb436) - -## BBOT Modules By Flag -For a full list of modules, including the data types consumed and emitted by each one, see [List of Modules](https://www.blacklanternsecurity.com/bbot/modules/list_of_modules/). - - -| Flag | # Modules | Description | Modules | -|------------------|-------------|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| safe | 84 | Non-intrusive, safe to run | affiliates, aggregate, ajaxpro, anubisdb, asn, azure_realm, azure_tenant, baddns, baddns_zone, badsecrets, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, code_repository, columbus, credshed, crobat, crt, dehashed, digitorus, dnscaa, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, filedownload, fingerprintx, fullhunt, git, git_clone, github_codesearch, github_org, github_workflows, gitlab, gowitness, hackertarget, httpx, hunt, hunterio, iis_shortnames, internetdb, ip2location, ipstack, leakix, myssl, newsletters, ntlm, oauth, otx, passivetotal, pgp, postman, rapiddns, riddler, robots, secretsdb, securitytrails, shodan_dns, sitedossier, skymem, social, sslcert, subdomaincenter, sublist3r, threatminer, trufflehog, unstructured, urlscan, viewdns, virustotal, wappalyzer, wayback, zoomeye | -| passive | 64 | Never connects to target systems | affiliates, aggregate, anubisdb, asn, azure_realm, azure_tenant, bevigil, binaryedge, bucket_file_enum, builtwith, c99, censys, certspotter, chaos, code_repository, columbus, credshed, crobat, crt, dehashed, digitorus, dnscaa, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, excavate, fullhunt, git_clone, github_codesearch, github_org, github_workflows, hackertarget, hunterio, internetdb, ip2location, ipneighbor, ipstack, leakix, massdns, myssl, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, social, speculate, subdomaincenter, sublist3r, threatminer, trufflehog, unstructured, urlscan, viewdns, virustotal, wayback, zoomeye | -| subdomain-enum | 46 | Enumerates subdomains | anubisdb, asn, azure_realm, azure_tenant, baddns_zone, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crt, digitorus, dnscaa, dnscommonsrv, dnsdumpster, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, internetdb, ipneighbor, leakix, massdns, myssl, oauth, otx, passivetotal, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, sslcert, subdomaincenter, subdomains, threatminer, urlscan, virustotal, wayback, zoomeye | -| active | 43 | Makes active connections to target systems | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab, gowitness, host_header, httpx, hunt, iis_shortnames, masscan, newsletters, nmap, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wappalyzer, wpscan | -| web-thorough | 29 | More advanced web scanning functionality | ajaxpro, azure_realm, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf_shortnames, filedownload, generic_ssrf, git, host_header, httpx, hunt, iis_shortnames, nmap, ntlm, oauth, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, wappalyzer | -| aggressive | 21 | Generates a large amount of network traffic | bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, generic_ssrf, host_header, ipneighbor, masscan, massdns, nmap, nuclei, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, telerik, url_manipulation, vhost, wafw00f, wpscan | -| web-basic | 17 | Basic, non-intrusive web scan functionality | azure_realm, baddns, badsecrets, bucket_amazon, bucket_azure, bucket_firebase, bucket_google, filedownload, git, httpx, iis_shortnames, ntlm, oauth, robots, secretsdb, sslcert, wappalyzer | -| cloud-enum | 12 | Enumerates cloud resources | azure_realm, azure_tenant, baddns, baddns_zone, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, httpx, oauth | -| slow | 10 | May take a long time to complete | bucket_digitalocean, dastardly, docker_pull, fingerprintx, git_clone, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, vhost | -| affiliates | 8 | Discovers affiliated hostnames/domains | affiliates, azure_realm, azure_tenant, builtwith, oauth, sslcert, viewdns, zoomeye | -| email-enum | 8 | Enumerates email addresses | dehashed, dnscaa, emailformat, emails, hunterio, pgp, skymem, sslcert | -| deadly | 4 | Highly aggressive | dastardly, ffuf, nuclei, vhost | -| portscan | 3 | Discovers open ports | internetdb, masscan, nmap | -| web-paramminer | 3 | Discovers HTTP parameters through brute-force | paramminer_cookies, paramminer_getparams, paramminer_headers | -| baddns | 2 | Runs all modules from the DNS auditing tool BadDNS | baddns, baddns_zone | -| iis-shortnames | 2 | Scans for IIS Shortname vulnerability | ffuf_shortnames, iis_shortnames | -| report | 2 | Generates a report at the end of the scan | affiliates, asn | -| social-enum | 2 | Enumerates social media | httpx, social | -| repo-enum | 1 | Enumerates code repositories | code_repository | -| service-enum | 1 | Identifies protocols running on open ports | fingerprintx | -| subdomain-hijack | 1 | Detects hijackable subdomains | baddns | -| web-screenshots | 1 | Takes screenshots of web pages | gowitness | - - -## BBOT Output Modules -BBOT can save its data to TXT, CSV, JSON, and tons of other destinations including [Neo4j](https://www.blacklanternsecurity.com/bbot/scanning/output/#neo4j), [Splunk](https://www.blacklanternsecurity.com/bbot/scanning/output/#splunk), and [Discord](https://www.blacklanternsecurity.com/bbot/scanning/output/#discord-slack-teams). For instructions on how to use these, see [Output Modules](https://www.blacklanternsecurity.com/bbot/scanning/output). - - -| Module | Type | Needs API Key | Description | Flags | Consumed Events | Produced Events | -|-----------------|--------|-----------------|-----------------------------------------------------------------------------------------|----------------|--------------------------------------------------------------------------------------------------|---------------------------| -| asset_inventory | output | No | Merge hosts, open ports, technologies, findings, etc. into a single asset inventory CSV | | DNS_NAME, FINDING, HTTP_RESPONSE, IP_ADDRESS, OPEN_TCP_PORT, TECHNOLOGY, URL, VULNERABILITY, WAF | IP_ADDRESS, OPEN_TCP_PORT | -| csv | output | No | Output to CSV | | * | | -| discord | output | No | Message a Discord channel when certain events are encountered | | * | | -| emails | output | No | Output any email addresses found belonging to the target domain | email-enum | EMAIL_ADDRESS | | -| http | output | No | Send every event to a custom URL via a web request | | * | | -| human | output | No | Output to text | | * | | -| json | output | No | Output to Newline-Delimited JSON (NDJSON) | | * | | -| neo4j | output | No | Output to Neo4j | | * | | -| python | output | No | Output via Python API | | * | | -| slack | output | No | Message a Slack channel when certain events are encountered | | * | | -| splunk | output | No | Send every event to a splunk instance through HTTP Event Collector | | * | | -| subdomains | output | No | Output only resolved, in-scope subdomains | subdomain-enum | DNS_NAME, DNS_NAME_UNRESOLVED | | -| teams | output | No | Message a Teams channel when certain events are encountered | | * | | -| web_report | output | No | Create a markdown report with web assets | | FINDING, TECHNOLOGY, URL, VHOST, VULNERABILITY | | -| websocket | output | No | Output to websockets | | * | | - +- Aleksei Kornev (@alekseiko) for granting us ownership of the bbot Pypi repository <3 diff --git a/bbot/__init__.py b/bbot/__init__.py index 1d95273e3..8746d8131 100644 --- a/bbot/__init__.py +++ b/bbot/__init__.py @@ -1,10 +1,4 @@ # version placeholder (replaced by poetry-dynamic-versioning) -__version__ = "0.0.0" +__version__ = "v0.0.0" -# global app config -from .core import configurator - -config = configurator.config - -# helpers -from .core import helpers +from .scanner import Scanner, Preset diff --git a/bbot/agent/__init__.py b/bbot/agent/__init__.py deleted file mode 100644 index d2361b7a3..000000000 --- a/bbot/agent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .agent import Agent diff --git a/bbot/agent/agent.py b/bbot/agent/agent.py deleted file mode 100644 index 1c8debc1e..000000000 --- a/bbot/agent/agent.py +++ /dev/null @@ -1,204 +0,0 @@ -import json -import asyncio -import logging -import traceback -import websockets -from omegaconf import OmegaConf - -from . import messages -import bbot.core.errors -from bbot.scanner import Scanner -from bbot.scanner.dispatcher import Dispatcher -from bbot.core.helpers.misc import urlparse, split_host_port -from bbot.core.configurator.environ import prepare_environment - -log = logging.getLogger("bbot.core.agent") - - -class Agent: - def __init__(self, config): - self.config = config - prepare_environment(self.config) - self.url = self.config.get("agent_url", "") - self.parsed_url = urlparse(self.url) - self.host, self.port = split_host_port(self.parsed_url.netloc) - self.token = self.config.get("agent_token", "") - self.scan = None - self.task = None - self._ws = None - self._scan_lock = asyncio.Lock() - - self.dispatcher = Dispatcher() - self.dispatcher.on_status = self.on_scan_status - self.dispatcher.on_finish = self.on_scan_finish - - def setup(self): - if not self.url: - log.error(f"Must specify agent_url") - return False - if not self.token: - log.error(f"Must specify agent_token") - return False - return True - - async def ws(self, rebuild=False): - if self._ws is None or rebuild: - kwargs = {"close_timeout": 0.5} - if self.token: - kwargs.update({"extra_headers": {"Authorization": f"Bearer {self.token}"}}) - verbs = ("Building", "Built") - if rebuild: - verbs = ("Rebuilding", "Rebuilt") - url = f"{self.url}/control/" - log.debug(f"{verbs[0]} websocket connection to {url}") - while 1: - try: - self._ws = await websockets.connect(url, **kwargs) - break - except Exception as e: - log.error(f'Failed to establish websockets connection to URL "{url}": {e}') - log.trace(traceback.format_exc()) - await asyncio.sleep(1) - log.debug(f"{verbs[1]} websocket connection to {url}") - return self._ws - - async def start(self): - rebuild = False - while 1: - ws = await self.ws(rebuild=rebuild) - rebuild = False - try: - message = await ws.recv() - log.debug(f"Got message: {message}") - try: - message = json.loads(message) - message = messages.Message(**message) - - if message.command == "ping": - if self.scan is None: - await self.send({"conversation": str(message.conversation), "message_type": "pong"}) - continue - - command_type = getattr(messages, message.command, None) - if command_type is None: - log.warning(f'Invalid command: "{message.command}"') - continue - - command_args = command_type(**message.arguments) - command_fn = getattr(self, message.command) - response = await self.err_handle(command_fn, **command_args.dict()) - log.info(str(response)) - await self.send({"conversation": str(message.conversation), "message": response}) - - except json.decoder.JSONDecodeError as e: - log.warning(f'Failed to decode message "{message}": {e}') - log.trace(traceback.format_exc()) - continue - except Exception as e: - log.debug(f"Error receiving message: {e}") - log.debug(traceback.format_exc()) - await asyncio.sleep(1) - rebuild = True - - async def send(self, message): - rebuild = False - while 1: - try: - ws = await self.ws(rebuild=rebuild) - j = json.dumps(message) - log.debug(f"Sending message of length {len(message)}") - await ws.send(j) - rebuild = False - break - except Exception as e: - log.warning(f"Error sending message: {e}, retrying") - log.trace(traceback.format_exc()) - await asyncio.sleep(1) - # rebuild = True - - async def start_scan(self, scan_id, name=None, targets=[], modules=[], output_modules=[], config={}): - async with self._scan_lock: - if self.scan is None: - log.success( - f"Starting scan with targets={targets}, modules={modules}, output_modules={output_modules}" - ) - output_module_config = OmegaConf.create( - {"output_modules": {"websocket": {"url": f"{self.url}/scan/{scan_id}/", "token": self.token}}} - ) - config = OmegaConf.create(config) - config = OmegaConf.merge(self.config, config, output_module_config) - output_modules = list(set(output_modules + ["websocket"])) - scan = Scanner( - *targets, - scan_id=scan_id, - name=name, - modules=modules, - output_modules=output_modules, - config=config, - dispatcher=self.dispatcher, - ) - self.task = asyncio.create_task(self._start_scan_task(scan)) - - return {"success": f"Started scan", "scan_id": scan.id} - else: - msg = f"Scan {self.scan.id} already in progress" - log.warning(msg) - return {"error": msg, "scan_id": self.scan.id} - - async def _start_scan_task(self, scan): - self.scan = scan - try: - await scan.async_start_without_generator() - except bbot.core.errors.ScanError as e: - log.error(f"Scan error: {e}") - log.trace(traceback.format_exc()) - except Exception: - log.critical(f"Encountered error: {traceback.format_exc()}") - self.on_scan_status("FAILED", scan.id) - finally: - self.task = None - - async def stop_scan(self): - log.warning("Stopping scan") - try: - async with self._scan_lock: - if self.scan is None: - msg = "Scan not in progress" - log.warning(msg) - return {"error": msg} - scan_id = str(self.scan.id) - self.scan.stop() - msg = f"Stopped scan {scan_id}" - log.warning(msg) - self.scan = None - return {"success": msg, "scan_id": scan_id} - except Exception as e: - log.warning(f"Error while stopping scan: {e}") - log.trace(traceback.format_exc()) - finally: - self.scan = None - self.task = None - - async def scan_status(self): - async with self._scan_lock: - if self.scan is None: - msg = "Scan not in progress" - log.warning(msg) - return {"error": msg} - return {"success": "Polled scan", "scan_status": self.scan.status} - - async def on_scan_status(self, status, scan_id): - await self.send({"message_type": "scan_status_change", "status": str(status), "scan_id": scan_id}) - - async def on_scan_finish(self, scan): - self.scan = None - self.task = None - - async def err_handle(self, callback, *args, **kwargs): - try: - return await callback(*args, **kwargs) - except Exception as e: - msg = f"Error in {callback.__qualname__}(): {e}" - log.error(msg) - log.trace(traceback.format_exc()) - return {"error": msg} diff --git a/bbot/agent/messages.py b/bbot/agent/messages.py deleted file mode 100644 index 34fd2c15c..000000000 --- a/bbot/agent/messages.py +++ /dev/null @@ -1,29 +0,0 @@ -from uuid import UUID -from typing import Optional -from pydantic import BaseModel - - -class Message(BaseModel): - conversation: UUID - command: str - arguments: Optional[dict] = {} - - -### COMMANDS ### - - -class start_scan(BaseModel): - scan_id: str - targets: list - modules: list - output_modules: list = [] - config: dict = {} - name: Optional[str] = None - - -class stop_scan(BaseModel): - pass - - -class scan_status(BaseModel): - pass diff --git a/bbot/cli.py b/bbot/cli.py index 9427c063f..6c9718fca 100755 --- a/bbot/cli.py +++ b/bbot/cli.py @@ -1,420 +1,291 @@ #!/usr/bin/env python3 -import os -import re import sys -import asyncio import logging -import traceback -from omegaconf import OmegaConf -from contextlib import suppress - -# fix tee buffering -sys.stdout.reconfigure(line_buffering=True) - -# logging -from bbot.core.logger import get_log_level, toggle_log_level - -import bbot.core.errors +import multiprocessing +from bbot.errors import * from bbot import __version__ -from bbot.modules import module_loader +from bbot.logger import log_to_stderr from bbot.core.helpers.misc import chain_lists -from bbot.core.configurator.args import parser -from bbot.core.helpers.logger import log_to_stderr -from bbot.core.configurator import ensure_config_files, check_cli_args, environ -log = logging.getLogger("bbot.cli") +if multiprocessing.current_process().name == "MainProcess": + silent = "-s" in sys.argv or "--silent" in sys.argv + + if not silent: + ascii_art = rf"""  ______  _____ ____ _______ + | ___ \| __ \ / __ \__ __| + | |___) | |__) | | | | | | + | ___ <| __ <| | | | | | + | |___) | |__) | |__| | | | + |______/|_____/ \____/ |_| + BIGHUGE BLS OSINT TOOL {__version__} + +www.blacklanternsecurity.com/bbot +""" + print(ascii_art, file=sys.stderr) + log_to_stderr( + "This is a pre-release of BBOT 2.0. If you upgraded from version 1, we recommend cleaning your old configs etc. before running this version!", + level="WARNING", + ) + log_to_stderr( + "For details, see https://github.com/blacklanternsecurity/bbot/discussions/1540", level="WARNING" + ) + +scan_name = "" -log_level = get_log_level() +async def _main(): -from . import config + import asyncio + import traceback + from contextlib import suppress + # fix tee buffering + sys.stdout.reconfigure(line_buffering=True) -err = False -scan_name = "" + log = logging.getLogger("bbot.cli") + from bbot.scanner import Scanner + from bbot.scanner.preset import Preset -async def _main(): - global err global scan_name - environ.cli_execution = True - - # async def monitor_tasks(): - # in_row = 0 - # while 1: - # try: - # print('looooping') - # tasks = asyncio.all_tasks() - # current_task = asyncio.current_task() - # if len(tasks) == 1 and list(tasks)[0] == current_task: - # print('no tasks') - # in_row += 1 - # else: - # in_row = 0 - # for t in tasks: - # print(t) - # if in_row > 2: - # break - # await asyncio.sleep(1) - # except BaseException as e: - # print(traceback.format_exc()) - # with suppress(BaseException): - # await asyncio.sleep(.1) - - # monitor_tasks_task = asyncio.create_task(monitor_tasks()) - - ensure_config_files() try: + + # start by creating a default scan preset + preset = Preset(_log=True, name="bbot_cli_main") + # parse command line arguments and merge into preset + try: + preset.parse_args() + except BBOTArgumentError as e: + log_to_stderr(str(e), level="WARNING") + log.trace(traceback.format_exc()) + return + # ensure arguments (-c config options etc.) are valid + options = preset.args.parsed + + # print help if no arguments if len(sys.argv) == 1: - parser.print_help() + print(preset.args.parser.format_help()) sys.exit(1) - - options = parser.parse_args() - check_cli_args() + return # --version if options.version: - log.stdout(__version__) + print(__version__) sys.exit(0) return - # --current-config - if options.current_config: - log.stdout(f"{OmegaConf.to_yaml(config)}") + # --list-presets + if options.list_presets: + print("") + print("### PRESETS ###") + print("") + for row in preset.presets_table().splitlines(): + print(row) + return + + # if we're listing modules or their options + if options.list_modules or options.list_module_options: + + # if no modules or flags are specified, enable everything + if not (options.modules or options.output_modules or options.flags): + for module, preloaded in preset.module_loader.preloaded().items(): + module_type = preloaded.get("type", "scan") + preset.add_module(module, module_type=module_type) + + if options.modules or options.output_modules or options.flags: + preset._default_output_modules = options.output_modules + preset._default_internal_modules = [] + + preset.bake() + + # --list-modules + if options.list_modules: + print("") + print("### MODULES ###") + print("") + for row in preset.module_loader.modules_table(preset.modules).splitlines(): + print(row) + return + + # --list-module-options + if options.list_module_options: + print("") + print("### MODULE OPTIONS ###") + print("") + for row in preset.module_loader.modules_options_table(preset.modules).splitlines(): + print(row) + return + + # --list-flags + if options.list_flags: + flags = preset.flags if preset.flags else None + print("") + print("### FLAGS ###") + print("") + for row in preset.module_loader.flags_table(flags=flags).splitlines(): + print(row) + return + + try: + scan = Scanner(preset=preset) + except (PresetAbortError, ValidationError) as e: + log.warning(str(e)) + return + + deadly_modules = [ + m for m in scan.preset.scan_modules if "deadly" in 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)}") + log.hugewarning(f"Deadly modules are highly intrusive") + log.hugewarning(f"Please specify --allow-deadly to continue") + return False + + # --current-preset + if options.current_preset: + print(scan.preset.to_yaml()) sys.exit(0) return - if options.agent_mode: - from bbot.agent import Agent - - agent = Agent(config) - success = agent.setup() - if success: - await agent.start() - - else: - from bbot.scanner import Scanner - - try: - output_modules = set(options.output_modules) - module_filtering = False - if (options.list_modules or options.help_all) and not any([options.flags, options.modules]): - module_filtering = True - modules = set(module_loader.preloaded(type="scan")) - else: - modules = set(options.modules) - # enable modules by flags - for m, c in module_loader.preloaded().items(): - module_type = c.get("type", "scan") - if m not in modules: - flags = c.get("flags", []) - if "deadly" in flags: - continue - for f in options.flags: - if f in flags: - log.verbose(f'Enabling {m} because it has flag "{f}"') - if module_type == "output": - output_modules.add(m) - else: - modules.add(m) - - default_output_modules = ["human", "json", "csv"] - - # Make a list of the modules which can be output to the console - consoleable_output_modules = [ - k for k, v in module_loader.preloaded(type="output").items() if "console" in v["config"] - ] - - # if none of the output modules provided on the command line are consoleable, don't turn off the defaults. Instead, just add the one specified to the defaults. - if not any(o in consoleable_output_modules for o in output_modules): - output_modules.update(default_output_modules) - - scanner = Scanner( - *options.targets, - modules=list(modules), - output_modules=list(output_modules), - output_dir=options.output_dir, - config=config, - name=options.name, - whitelist=options.whitelist, - blacklist=options.blacklist, - strict_scope=options.strict_scope, - force_start=options.force, - ) - - if options.install_all_deps: - all_modules = list(module_loader.preloaded()) - scanner.helpers.depsinstaller.force_deps = True - succeeded, failed = await scanner.helpers.depsinstaller.install(*all_modules) - log.info("Finished installing module dependencies") - return False if failed else True - - scan_name = str(scanner.name) - - # enable modules by dependency - # this is only a basic surface-level check - # todo: recursive dependency graph with networkx or topological sort? - all_modules = list(set(scanner._scan_modules + scanner._internal_modules + scanner._output_modules)) - while 1: - changed = False - dep_choices = module_loader.recommend_dependencies(all_modules) - if not dep_choices: - break - for event_type, deps in dep_choices.items(): - if event_type in ("*", "all"): - continue - # skip resolving dependency if a target provides the missing type - if any(e.type == event_type for e in scanner.target.events): - continue - required_by = deps.get("required_by", []) - recommended = deps.get("recommended", []) - if not recommended: - log.hugewarning( - f"{len(required_by):,} modules ({','.join(required_by)}) rely on {event_type} but no modules produce it" - ) - elif len(recommended) == 1: - log.verbose( - f"Enabling {next(iter(recommended))} because {len(required_by):,} modules ({','.join(required_by)}) rely on it for {event_type}" - ) - all_modules = list(set(all_modules + list(recommended))) - scanner._scan_modules = list(set(scanner._scan_modules + list(recommended))) - changed = True - else: - log.hugewarning( - f"{len(required_by):,} modules ({','.join(required_by)}) rely on {event_type} but no enabled module produces it" - ) - log.hugewarning( - f"Recommend enabling one or more of the following modules which produce {event_type}:" + # --current-preset-full + if options.current_preset_full: + print(scan.preset.to_yaml(full_config=True)) + sys.exit(0) + return + + # --install-all-deps + if options.install_all_deps: + all_modules = list(preset.module_loader.preloaded()) + scan.helpers.depsinstaller.force_deps = True + succeeded, failed = await scan.helpers.depsinstaller.install(*all_modules) + log.info("Finished installing module dependencies") + return False if failed else True + + scan_name = str(scan.name) + + log.verbose("") + log.verbose("### MODULES ENABLED ###") + log.verbose("") + for row in scan.preset.module_loader.modules_table(scan.preset.modules).splitlines(): + log.verbose(row) + + scan.helpers.word_cloud.load() + await scan._prep() + + if not options.dry_run: + log.trace(f"Command: {' '.join(sys.argv)}") + + if sys.stdin.isatty(): + + # warn if any targets belong directly to a cloud provider + for event in scan.target.events: + if event.type == "DNS_NAME": + cloudcheck_result = scan.helpers.cloudcheck(event.host) + if cloudcheck_result: + scan.hugewarning( + f'YOUR TARGET CONTAINS A CLOUD DOMAIN: "{event.host}". You\'re in for a wild ride!' ) - for m in recommended: - log.warning(f" - {m}") - if not changed: - break - - # required flags - modules = set(scanner._scan_modules) - for m in scanner._scan_modules: - flags = module_loader._preloaded.get(m, {}).get("flags", []) - if not all(f in flags for f in options.require_flags): - log.verbose( - f"Removing {m} because it does not have the required flags: {'+'.join(options.require_flags)}" - ) - with suppress(KeyError): - modules.remove(m) - - # excluded flags - for m in scanner._scan_modules: - flags = module_loader._preloaded.get(m, {}).get("flags", []) - if any(f in flags for f in options.exclude_flags): - log.verbose(f"Removing {m} because of excluded flag: {','.join(options.exclude_flags)}") - with suppress(KeyError): - modules.remove(m) - - # excluded modules - for m in options.exclude_modules: - if m in modules: - log.verbose(f"Removing {m} because it is excluded") - with suppress(KeyError): - modules.remove(m) - scanner._scan_modules = list(modules) - - log_fn = log.info - if options.list_modules or options.help_all: - log_fn = log.stdout - - help_modules = list(modules) - if module_filtering: - help_modules = None - - if options.help_all: - log_fn(parser.format_help()) - - if options.list_flags: - log.stdout("") - log.stdout("### FLAGS ###") - log.stdout("") - for row in module_loader.flags_table(flags=options.flags).splitlines(): - log.stdout(row) - return - - log_fn("") - log_fn("### MODULES ###") - log_fn("") - for row in module_loader.modules_table(modules=help_modules).splitlines(): - log_fn(row) - - if options.help_all: - log_fn("") - log_fn("### MODULE OPTIONS ###") - log_fn("") - for row in module_loader.modules_options_table(modules=help_modules).splitlines(): - log_fn(row) - - if options.list_modules or options.list_flags or options.help_all: - return - - module_list = module_loader.filter_modules(modules=modules) - deadly_modules = [] - active_modules = [] - active_aggressive_modules = [] - slow_modules = [] - for m in module_list: - if m[0] in scanner._scan_modules: - if "deadly" in m[-1]["flags"]: - deadly_modules.append(m[0]) - if "active" in m[-1]["flags"]: - active_modules.append(m[0]) - if "aggressive" in m[-1]["flags"]: - active_aggressive_modules.append(m[0]) - if "slow" in m[-1]["flags"]: - slow_modules.append(m[0]) - if scanner._scan_modules: - if deadly_modules and not options.allow_deadly: - log.hugewarning(f"You enabled the following deadly modules: {','.join(deadly_modules)}") - log.hugewarning(f"Deadly modules are highly intrusive") - log.hugewarning(f"Please specify --allow-deadly to continue") - return False - if active_modules: - if active_modules: - if active_aggressive_modules: - log.hugewarning( - "This is an (aggressive) active scan! Intrusive connections will be made to target" - ) - else: - log.hugewarning( - "This is a (safe) active scan. Non-intrusive connections will be made to target" - ) + + if not options.yes: + log.hugesuccess(f"Scan ready. Press enter to execute {scan.name}") + input() + + import os + import re + import fcntl + from bbot.core.helpers.misc import smart_decode + + def handle_keyboard_input(keyboard_input): + kill_regex = re.compile(r"kill (?P[a-z0-9_ ,]+)") + if keyboard_input: + log.verbose(f'Got keyboard input: "{keyboard_input}"') + kill_match = kill_regex.match(keyboard_input) + if kill_match: + modules = kill_match.group("modules") + if modules: + modules = chain_lists(modules) + for module in modules: + if module in scan.modules: + log.hugewarning(f'Killing module: "{module}"') + scan.kill_module(module, message="killed by user") + else: + log.warning(f'Invalid module: "{module}"') else: - log.hugeinfo("This is a passive scan. No connections will be made to target") - if slow_modules: - log.warning( - f"You have enabled the following slow modules: {','.join(slow_modules)}. Scan may take a while" - ) - - scanner.helpers.word_cloud.load() - - await scanner._prep() - - if not options.dry_run: - log.trace(f"Command: {' '.join(sys.argv)}") - - # if we're on the terminal, enable keyboard interaction - if sys.stdin.isatty(): - - # warn if any targets belong directly to a cloud provider - for event in scanner.target.events: - if event.type == "DNS_NAME": - provider, _, _ = scanner.helpers.cloudcheck(event.host) - if provider: - scanner.hugewarning( - f'YOUR TARGET CONTAINS A CLOUD DOMAIN: "{event.host}". You\'re in for a wild ride!' - ) - - import fcntl - from bbot.core.helpers.misc import smart_decode - - if not options.agent_mode and not options.yes: - log.hugesuccess(f"Scan ready. Press enter to execute {scanner.name}") - input() - - def handle_keyboard_input(keyboard_input): - kill_regex = re.compile(r"kill (?P[a-z0-9_ ,]+)") - if keyboard_input: - log.verbose(f'Got keyboard input: "{keyboard_input}"') - kill_match = kill_regex.match(keyboard_input) - if kill_match: - modules = kill_match.group("modules") - if modules: - modules = chain_lists(modules) - for module in modules: - if module in scanner.modules: - log.hugewarning(f'Killing module: "{module}"') - scanner.manager.kill_module(module, message="killed by user") - else: - log.warning(f'Invalid module: "{module}"') - else: - toggle_log_level(logger=log) - scanner.manager.modules_status(_log=True) - - reader = asyncio.StreamReader() - protocol = asyncio.StreamReaderProtocol(reader) - await asyncio.get_event_loop().connect_read_pipe(lambda: protocol, sys.stdin) - - # set stdout and stderr to blocking mode - # this is needed to prevent BlockingIOErrors in logging etc. - fds = [sys.stdout.fileno(), sys.stderr.fileno()] - for fd in fds: - flags = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) - - async def akeyboard_listen(): + scan.preset.core.logger.toggle_log_level(logger=log) + scan.modules_status(_log=True) + + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + await asyncio.get_event_loop().connect_read_pipe(lambda: protocol, sys.stdin) + + # set stdout and stderr to blocking mode + # this is needed to prevent BlockingIOErrors in logging etc. + fds = [sys.stdout.fileno(), sys.stderr.fileno()] + for fd in fds: + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) + + async def akeyboard_listen(): + try: + allowed_errors = 10 + while 1: + keyboard_input = None try: + keyboard_input = smart_decode((await reader.readline()).strip()) allowed_errors = 10 - while 1: - keyboard_input = None - try: - keyboard_input = smart_decode((await reader.readline()).strip()) - allowed_errors = 10 - except Exception as e: - log_to_stderr(f"Error in keyboard listen loop: {e}", level="TRACE") - log_to_stderr(traceback.format_exc(), level="TRACE") - allowed_errors -= 1 - if keyboard_input is not None: - handle_keyboard_input(keyboard_input) - if allowed_errors <= 0: - break except Exception as e: - log_to_stderr(f"Error in keyboard listen task: {e}", level="ERROR") + log_to_stderr(f"Error in keyboard listen loop: {e}", level="TRACE") log_to_stderr(traceback.format_exc(), level="TRACE") + allowed_errors -= 1 + if keyboard_input is not None: + handle_keyboard_input(keyboard_input) + if allowed_errors <= 0: + break + except Exception as e: + log_to_stderr(f"Error in keyboard listen task: {e}", level="ERROR") + log_to_stderr(traceback.format_exc(), level="TRACE") - asyncio.create_task(akeyboard_listen()) + asyncio.create_task(akeyboard_listen()) - await scanner.async_start_without_generator() + await scan.async_start_without_generator() - except bbot.core.errors.ScanError as e: - log_to_stderr(str(e), level="ERROR") - except Exception: - raise + return True - except bbot.core.errors.BBOTError as e: - log_to_stderr(f"{e} (--debug for details)", level="ERROR") - if log_level <= logging.DEBUG: - log_to_stderr(traceback.format_exc(), level="DEBUG") - err = True - - except Exception: - log_to_stderr(f"Encountered unknown error: {traceback.format_exc()}", level="ERROR") - err = True + except BBOTError as e: + log.error(str(e)) + log.trace(traceback.format_exc()) finally: # save word cloud with suppress(BaseException): - save_success, filename = scanner.helpers.word_cloud.save() + save_success, filename = scan.helpers.word_cloud.save() if save_success: - log_to_stderr(f"Saved word cloud ({len(scanner.helpers.word_cloud):,} words) to {filename}") + log_to_stderr(f"Saved word cloud ({len(scan.helpers.word_cloud):,} words) to {filename}") # remove output directory if empty with suppress(BaseException): - scanner.home.rmdir() - if err: - os._exit(1) + scan.home.rmdir() def main(): + import asyncio + import traceback + from bbot.core import CORE + global scan_name try: asyncio.run(_main()) except asyncio.CancelledError: - if get_log_level() <= logging.DEBUG: + if CORE.logger.log_level <= logging.DEBUG: log_to_stderr(traceback.format_exc(), level="DEBUG") except KeyboardInterrupt: msg = "Interrupted" if scan_name: msg = f"You killed {scan_name}" log_to_stderr(msg, level="WARNING") - if get_log_level() <= logging.DEBUG: + if CORE.logger.log_level <= logging.DEBUG: log_to_stderr(traceback.format_exc(), level="DEBUG") exit(1) diff --git a/bbot/core/__init__.py b/bbot/core/__init__.py index 52cf06cc5..6cfaecf0f 100644 --- a/bbot/core/__init__.py +++ b/bbot/core/__init__.py @@ -1,4 +1,3 @@ -# logging -from .logger import init_logging +from .core import BBOTCore -init_logging() +CORE = BBOTCore() diff --git a/bbot/core/config/__init__.py b/bbot/core/config/__init__.py new file mode 100644 index 000000000..c36d91f48 --- /dev/null +++ b/bbot/core/config/__init__.py @@ -0,0 +1,12 @@ +import sys +import multiprocessing as mp + +try: + mp.set_start_method("spawn") +except Exception: + start_method = mp.get_start_method() + if start_method != "spawn": + print( + f"[WARN] Multiprocessing spawn method is set to {start_method}. This may negatively affect performance.", + file=sys.stderr, + ) diff --git a/bbot/core/config/files.py b/bbot/core/config/files.py new file mode 100644 index 000000000..c66e92116 --- /dev/null +++ b/bbot/core/config/files.py @@ -0,0 +1,42 @@ +import sys +from pathlib import Path +from omegaconf import OmegaConf + +from ...logger import log_to_stderr +from ...errors import ConfigLoadError + + +bbot_code_dir = Path(__file__).parent.parent.parent + + +class BBOTConfigFiles: + + config_dir = (Path.home() / ".config" / "bbot").resolve() + defaults_filename = (bbot_code_dir / "defaults.yml").resolve() + config_filename = (config_dir / "bbot.yml").resolve() + secrets_filename = (config_dir / "secrets.yml").resolve() + + def __init__(self, core): + self.core = core + + def _get_config(self, filename, name="config"): + filename = Path(filename).resolve() + try: + conf = OmegaConf.load(str(filename)) + cli_silent = any(x in sys.argv for x in ("-s", "--silent")) + if __name__ == "__main__" and not cli_silent: + log_to_stderr(f"Loaded {name} from {filename}") + return conf + except Exception as e: + if filename.exists(): + raise ConfigLoadError(f"Error parsing config at {filename}:\n\n{e}") + return OmegaConf.create() + + def get_custom_config(self): + return OmegaConf.merge( + self._get_config(self.config_filename, name="config"), + self._get_config(self.secrets_filename, name="secrets"), + ) + + def get_default_config(self): + return self._get_config(self.defaults_filename, name="defaults") diff --git a/bbot/core/config/logger.py b/bbot/core/config/logger.py new file mode 100644 index 000000000..6a213d42d --- /dev/null +++ b/bbot/core/config/logger.py @@ -0,0 +1,246 @@ +import sys +import atexit +import logging +from copy import copy +import multiprocessing +import logging.handlers +from pathlib import Path + +from ..helpers.misc import mkdir, error_and_exit +from ...logger import colorize, loglevel_mapping + + +debug_format = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s %(filename)s:%(lineno)s %(message)s") + + +class ColoredFormatter(logging.Formatter): + """ + Pretty colors for terminal + """ + + formatter = logging.Formatter("%(levelname)s %(message)s") + module_formatter = logging.Formatter("%(levelname)s %(name)s: %(message)s") + + def format(self, record): + colored_record = copy(record) + levelname = colored_record.levelname + levelshort = loglevel_mapping.get(levelname, "INFO") + colored_record.levelname = colorize(f"[{levelshort}]", level=levelname) + if levelname == "CRITICAL" or levelname.startswith("HUGE"): + colored_record.msg = colorize(colored_record.msg, level=levelname) + # remove name + if colored_record.name.startswith("bbot.modules."): + colored_record.name = colored_record.name.split("bbot.modules.")[-1] + return self.module_formatter.format(colored_record) + return self.formatter.format(colored_record) + + +class BBOTLogger: + """ + The main BBOT logger. + + The job of this class is to manage the different log handlers in BBOT, + allow adding new log handlers, and easily switching log levels on the fly. + """ + + def __init__(self, core): + # custom logging levels + if getattr(logging, "HUGEWARNING", None) is None: + self.addLoggingLevel("TRACE", 49) + self.addLoggingLevel("HUGEWARNING", 31) + self.addLoggingLevel("HUGESUCCESS", 26) + self.addLoggingLevel("SUCCESS", 25) + self.addLoggingLevel("HUGEINFO", 21) + self.addLoggingLevel("HUGEVERBOSE", 16) + self.addLoggingLevel("VERBOSE", 15) + self.verbosity_levels_toggle = [logging.INFO, logging.VERBOSE, logging.DEBUG] + + self._loggers = None + self._log_handlers = None + self._log_level = None + self.root_logger = logging.getLogger() + self.core_logger = logging.getLogger("bbot") + self.core = core + + self.listener = None + + self.process_name = multiprocessing.current_process().name + if self.process_name == "MainProcess": + self.queue = multiprocessing.Queue() + self.setup_queue_handler() + # Start the QueueListener + self.listener = logging.handlers.QueueListener(self.queue, *self.log_handlers.values()) + self.listener.start() + atexit.register(self.listener.stop) + + self.log_level = logging.INFO + + def setup_queue_handler(self, logging_queue=None, log_level=logging.DEBUG): + if logging_queue is None: + logging_queue = self.queue + else: + self.queue = logging_queue + self.queue_handler = logging.handlers.QueueHandler(logging_queue) + + self.root_logger.addHandler(self.queue_handler) + + self.core_logger.setLevel(log_level) + # disable asyncio logging for child processes + if self.process_name != "MainProcess": + logging.getLogger("asyncio").setLevel(logging.ERROR) + + def addLoggingLevel(self, levelName, levelNum, methodName=None): + """ + Comprehensively adds a new logging level to the `logging` module and the + currently configured logging class. + + `levelName` becomes an attribute of the `logging` module with the value + `levelNum`. `methodName` becomes a convenience method for both `logging` + itself and the class returned by `logging.getLoggerClass()` (usually just + `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is + used. + + To avoid accidental clobberings of existing attributes, this method will + raise an `AttributeError` if the level name is already an attribute of the + `logging` module or if the method name is already present + + Example + ------- + >>> addLoggingLevel('TRACE', logging.DEBUG - 5) + >>> logging.getLogger(__name__).setLevel('TRACE') + >>> logging.getLogger(__name__).trace('that worked') + >>> logging.trace('so did this') + >>> logging.TRACE + 5 + + """ + if not methodName: + methodName = levelName.lower() + + if hasattr(logging, levelName): + raise AttributeError(f"{levelName} already defined in logging module") + if hasattr(logging, methodName): + raise AttributeError(f"{methodName} already defined in logging module") + if hasattr(logging.getLoggerClass(), methodName): + raise AttributeError(f"{methodName} already defined in logger class") + + # This method was inspired by the answers to Stack Overflow post + # http://stackoverflow.com/q/2183233/2988730, especially + # http://stackoverflow.com/a/13638084/2988730 + def logForLevel(self, message, *args, **kwargs): + if self.isEnabledFor(levelNum): + self._log(levelNum, message, args, **kwargs) + + def logToRoot(message, *args, **kwargs): + logging.log(levelNum, message, *args, **kwargs) + + logging.addLevelName(levelNum, levelName) + setattr(logging, levelName, levelNum) + setattr(logging.getLoggerClass(), methodName, logForLevel) + setattr(logging, methodName, logToRoot) + + @property + def loggers(self): + if self._loggers is None: + self._loggers = [ + logging.getLogger("bbot"), + logging.getLogger("asyncio"), + ] + return self._loggers + + def add_log_handler(self, handler, formatter=None): + if self.listener is None: + return + if handler.formatter is None: + handler.setFormatter(debug_format) + if handler not in self.listener.handlers: + self.listener.handlers = self.listener.handlers + (handler,) + + def remove_log_handler(self, handler): + if self.listener is None: + return + if handler in self.listener.handlers: + new_handlers = list(self.listener.handlers) + new_handlers.remove(handler) + self.listener.handlers = tuple(new_handlers) + + def include_logger(self, logger): + if logger not in self.loggers: + self.loggers.append(logger) + if self.log_level is not None: + logger.setLevel(self.log_level) + for handler in self.log_handlers.values(): + self.add_log_handler(handler) + + def stderr_filter(self, record): + if record.levelno == logging.TRACE and self.log_level > logging.DEBUG: + return False + if record.levelno < self.log_level: + return False + return True + + @property + def log_handlers(self): + if self._log_handlers is None: + log_dir = Path(self.core.home) / "logs" + if not mkdir(log_dir, raise_error=False): + error_and_exit(f"Failure creating or error writing to BBOT logs directory ({log_dir})") + + # Main log file + main_handler = logging.handlers.TimedRotatingFileHandler( + f"{log_dir}/bbot.log", when="d", interval=1, backupCount=14 + ) + + # Separate log file for debugging + debug_handler = logging.handlers.TimedRotatingFileHandler( + f"{log_dir}/bbot.debug.log", when="d", interval=1, backupCount=14 + ) + + # Log to stderr + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.addFilter(self.stderr_filter) + # log to files + debug_handler.addFilter(lambda x: x.levelno == logging.TRACE or (x.levelno < logging.VERBOSE)) + main_handler.addFilter(lambda x: x.levelno != logging.TRACE and x.levelno >= logging.VERBOSE) + + # Set log format + debug_handler.setFormatter(debug_format) + main_handler.setFormatter(debug_format) + stderr_handler.setFormatter(ColoredFormatter("%(levelname)s %(name)s: %(message)s")) + + self._log_handlers = { + "stderr": stderr_handler, + "file_debug": debug_handler, + "file_main": main_handler, + } + return self._log_handlers + + @property + def log_level(self): + if self._log_level is None: + return logging.INFO + return self._log_level + + @log_level.setter + def log_level(self, level): + self.set_log_level(level) + + def set_log_level(self, level, logger=None): + if isinstance(level, str): + level = logging.getLevelName(level) + if logger is not None: + logger.hugeinfo(f"Setting log level to {logging.getLevelName(level)}") + self._log_level = level + for logger in self.loggers: + logger.setLevel(level) + + def toggle_log_level(self, logger=None): + if self.log_level in self.verbosity_levels_toggle: + for i, level in enumerate(self.verbosity_levels_toggle): + if self.log_level == level: + self.set_log_level( + self.verbosity_levels_toggle[(i + 1) % len(self.verbosity_levels_toggle)], logger=logger + ) + break + else: + self.set_log_level(self.verbosity_levels_toggle[0], logger=logger) diff --git a/bbot/core/configurator/__init__.py b/bbot/core/configurator/__init__.py deleted file mode 100644 index 15962ce59..000000000 --- a/bbot/core/configurator/__init__.py +++ /dev/null @@ -1,103 +0,0 @@ -import re -from omegaconf import OmegaConf - -from . import files, args, environ -from ..errors import ConfigLoadError -from ...modules import module_loader -from ..helpers.logger import log_to_stderr -from ..helpers.misc import error_and_exit, filter_dict, clean_dict, match_and_exit, is_file - -# cached sudo password -bbot_sudo_pass = None - -modules_config = OmegaConf.create( - { - "modules": module_loader.configs(type="scan"), - "output_modules": module_loader.configs(type="output"), - "internal_modules": module_loader.configs(type="internal"), - } -) - -try: - config = OmegaConf.merge( - # first, pull module defaults - modules_config, - # then look in .yaml files - files.get_config(), - # finally, pull from CLI arguments - args.get_config(), - ) -except ConfigLoadError as e: - error_and_exit(e) - - -config = environ.prepare_environment(config) -default_config = OmegaConf.merge(files.default_config, modules_config) - - -sentinel = object() - - -exclude_from_validation = re.compile(r".*modules\.[a-z0-9_]+\.(?:batch_size|max_event_handlers)$") - - -def check_cli_args(): - conf = [a for a in args.cli_config if not is_file(a)] - all_options = None - for c in conf: - c = c.split("=")[0].strip() - v = OmegaConf.select(default_config, c, default=sentinel) - # if option isn't in the default config - if v is sentinel: - if exclude_from_validation.match(c): - continue - if all_options is None: - from ...modules import module_loader - - modules_options = set() - for module_options in module_loader.modules_options().values(): - modules_options.update(set(o[0] for o in module_options)) - global_options = set(default_config.keys()) - {"modules", "output_modules"} - all_options = global_options.union(modules_options) - match_and_exit(c, all_options, msg="module option") - - -def ensure_config_files(): - secrets_strings = ["api_key", "username", "password", "token", "secret", "_id"] - exclude_keys = ["modules", "output_modules", "internal_modules"] - - comment_notice = ( - "# NOTICE: THESE ENTRIES ARE COMMENTED BY DEFAULT\n" - + "# Please be sure to uncomment when inserting API keys, etc.\n" - ) - - # ensure bbot.yml - if not files.config_filename.exists(): - log_to_stderr(f"Creating BBOT config at {files.config_filename}") - no_secrets_config = OmegaConf.to_object(default_config) - no_secrets_config = clean_dict( - no_secrets_config, - *secrets_strings, - fuzzy=True, - exclude_keys=exclude_keys, - ) - yaml = OmegaConf.to_yaml(no_secrets_config) - yaml = comment_notice + "\n".join(f"# {line}" for line in yaml.splitlines()) - with open(str(files.config_filename), "w") as f: - f.write(yaml) - - # ensure secrets.yml - if not files.secrets_filename.exists(): - log_to_stderr(f"Creating BBOT secrets at {files.secrets_filename}") - secrets_only_config = OmegaConf.to_object(default_config) - secrets_only_config = filter_dict( - secrets_only_config, - *secrets_strings, - fuzzy=True, - exclude_keys=exclude_keys, - ) - yaml = OmegaConf.to_yaml(secrets_only_config) - yaml = comment_notice + "\n".join(f"# {line}" for line in yaml.splitlines()) - with open(str(files.secrets_filename), "w") as f: - f.write(yaml) - files.secrets_filename.chmod(0o600) diff --git a/bbot/core/configurator/args.py b/bbot/core/configurator/args.py deleted file mode 100644 index 173583827..000000000 --- a/bbot/core/configurator/args.py +++ /dev/null @@ -1,255 +0,0 @@ -import sys -import argparse -from pathlib import Path -from omegaconf import OmegaConf -from contextlib import suppress - -from ...modules import module_loader -from ..helpers.logger import log_to_stderr -from ..helpers.misc import chain_lists, match_and_exit, is_file - -module_choices = sorted(set(module_loader.configs(type="scan"))) -output_module_choices = sorted(set(module_loader.configs(type="output"))) - -flag_choices = set() -for m, c in module_loader.preloaded().items(): - flag_choices.update(set(c.get("flags", []))) - - -class BBOTArgumentParser(argparse.ArgumentParser): - _dummy = False - - def parse_args(self, *args, **kwargs): - """ - Allow space or comma-separated entries for modules and targets - For targets, also allow input files containing additional targets - """ - ret = super().parse_args(*args, **kwargs) - # silent implies -y - if ret.silent: - ret.yes = True - ret.modules = chain_lists(ret.modules) - ret.exclude_modules = chain_lists(ret.exclude_modules) - ret.output_modules = chain_lists(ret.output_modules) - ret.targets = chain_lists(ret.targets, try_files=True, msg="Reading targets from file: {filename}") - ret.whitelist = chain_lists(ret.whitelist, try_files=True, msg="Reading whitelist from file: {filename}") - ret.blacklist = chain_lists(ret.blacklist, try_files=True, msg="Reading blacklist from file: {filename}") - ret.flags = chain_lists(ret.flags) - ret.exclude_flags = chain_lists(ret.exclude_flags) - ret.require_flags = chain_lists(ret.require_flags) - for m in ret.modules: - if m not in module_choices and not self._dummy: - match_and_exit(m, module_choices, msg="module") - for m in ret.exclude_modules: - if m not in module_choices and not self._dummy: - match_and_exit(m, module_choices, msg="module") - for m in ret.output_modules: - if m not in output_module_choices and not self._dummy: - match_and_exit(m, output_module_choices, msg="output module") - for f in set(ret.flags + ret.require_flags): - if f not in flag_choices and not self._dummy: - if f not in flag_choices and not self._dummy: - match_and_exit(f, flag_choices, msg="flag") - return ret - - -class DummyArgumentParser(BBOTArgumentParser): - _dummy = True - - def error(self, message): - pass - - -scan_examples = [ - ( - "Subdomains", - "Perform a full subdomain enumeration on evilcorp.com", - "bbot -t evilcorp.com -f subdomain-enum", - ), - ( - "Subdomains (passive only)", - "Perform a passive-only subdomain enumeration on evilcorp.com", - "bbot -t evilcorp.com -f subdomain-enum -rf passive", - ), - ( - "Subdomains + port scan + web screenshots", - "Port-scan every subdomain, screenshot every webpage, output to current directory", - "bbot -t evilcorp.com -f subdomain-enum -m nmap gowitness -n my_scan -o .", - ), - ( - "Subdomains + basic web scan", - "A basic web scan includes wappalyzer, robots.txt, and other non-intrusive web modules", - "bbot -t evilcorp.com -f subdomain-enum web-basic", - ), - ( - "Web spider", - "Crawl www.evilcorp.com up to a max depth of 2, automatically extracting emails, secrets, etc.", - "bbot -t www.evilcorp.com -m httpx robots badsecrets secretsdb -c web_spider_distance=2 web_spider_depth=2", - ), - ( - "Everything everywhere all at once", - "Subdomains, emails, cloud buckets, port scan, basic web, web screenshots, nuclei", - "bbot -t evilcorp.com -f subdomain-enum email-enum cloud-enum web-basic -m nmap gowitness nuclei --allow-deadly", - ), -] - -usage_examples = [ - ( - "List modules", - "", - "bbot -l", - ), - ( - "List flags", - "", - "bbot -lf", - ), -] - - -epilog = "EXAMPLES\n" -for example in (scan_examples, usage_examples): - for title, description, command in example: - epilog += f"\n {title}:\n {command}\n" - - -parser = BBOTArgumentParser( - description="Bighuge BLS OSINT Tool", formatter_class=argparse.RawTextHelpFormatter, epilog=epilog -) -dummy_parser = DummyArgumentParser( - description="Bighuge BLS OSINT Tool", formatter_class=argparse.RawTextHelpFormatter, epilog=epilog -) -for p in (parser, dummy_parser): - p.add_argument("--help-all", action="store_true", help="Display full help including module config options") - target = p.add_argument_group(title="Target") - target.add_argument("-t", "--targets", nargs="+", default=[], help="Targets to seed the scan", metavar="TARGET") - target.add_argument( - "-w", - "--whitelist", - nargs="+", - default=[], - help="What's considered in-scope (by default it's the 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", - ) - modules = p.add_argument_group(title="Modules") - modules.add_argument( - "-m", - "--modules", - nargs="+", - default=[], - help=f'Modules to enable. Choices: {",".join(module_choices)}', - metavar="MODULE", - ) - modules.add_argument("-l", "--list-modules", action="store_true", help=f"List available modules.") - modules.add_argument( - "-em", "--exclude-modules", nargs="+", default=[], help=f"Exclude these modules.", metavar="MODULE" - ) - modules.add_argument( - "-f", - "--flags", - nargs="+", - default=[], - help=f'Enable modules by flag. Choices: {",".join(sorted(flag_choices))}', - metavar="FLAG", - ) - modules.add_argument("-lf", "--list-flags", action="store_true", help=f"List available flags.") - modules.add_argument( - "-rf", - "--require-flags", - nargs="+", - default=[], - help=f"Only enable modules with these flags (e.g. -rf passive)", - metavar="FLAG", - ) - modules.add_argument( - "-ef", - "--exclude-flags", - nargs="+", - default=[], - help=f"Disable modules with these flags. (e.g. -ef aggressive)", - metavar="FLAG", - ) - modules.add_argument( - "-om", - "--output-modules", - nargs="+", - default=["human", "json", "csv"], - help=f'Output module(s). Choices: {",".join(output_module_choices)}', - metavar="MODULE", - ) - modules.add_argument("--allow-deadly", action="store_true", help="Enable the use of highly aggressive modules") - scan = p.add_argument_group(title="Scan") - scan.add_argument("-n", "--name", help="Name of scan (default: random)", metavar="SCAN_NAME") - scan.add_argument( - "-o", - "--output-dir", - metavar="DIR", - ) - scan.add_argument( - "-c", - "--config", - nargs="*", - help="custom config file, or configuration options in key=value format: 'modules.shodan.api_key=1234'", - metavar="CONFIG", - ) - 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("--force", action="store_true", help="Run scan even if module setups fail") - scan.add_argument("-y", "--yes", action="store_true", help="Skip scan confirmation prompt") - scan.add_argument("--dry-run", action="store_true", help=f"Abort before executing scan") - scan.add_argument( - "--current-config", - action="store_true", - help="Show current config in YAML format", - ) - deps = p.add_argument_group( - title="Module dependencies", description="Control how modules install their dependencies" - ) - 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") - g2.add_argument("--retry-deps", action="store_true", help="Try again to install failed module dependencies") - 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") - agent = p.add_argument_group(title="Agent", description="Report back to a central server") - agent.add_argument("-a", "--agent-mode", action="store_true", help="Start in agent mode") - misc = p.add_argument_group(title="Misc") - misc.add_argument("--version", action="store_true", help="show BBOT version and exit") - - -cli_options = None -with suppress(Exception): - cli_options = dummy_parser.parse_args() - - -cli_config = [] - - -def get_config(): - global cli_config - with suppress(Exception): - if cli_options.config: - cli_config = cli_options.config - if cli_config: - filename = Path(cli_config[0]).resolve() - if len(cli_config) == 1 and is_file(filename): - try: - conf = OmegaConf.load(str(filename)) - log_to_stderr(f"Loaded custom config from {filename}") - return conf - except Exception as e: - log_to_stderr(f"Error parsing custom config at {filename}: {e}", level="ERROR") - sys.exit(2) - try: - return OmegaConf.from_cli(cli_config) - except Exception as e: - log_to_stderr(f"Error parsing command-line config: {e}", level="ERROR") - sys.exit(2) diff --git a/bbot/core/configurator/environ.py b/bbot/core/configurator/environ.py deleted file mode 100644 index 4358bb78d..000000000 --- a/bbot/core/configurator/environ.py +++ /dev/null @@ -1,153 +0,0 @@ -import os -import sys -import omegaconf -from pathlib import Path - -from . import args -from ...modules import module_loader -from ..helpers.misc import cpu_architecture, os_platform, os_platform_friendly - - -# keep track of whether BBOT is being executed via the CLI -cli_execution = False - - -def increase_limit(new_limit): - try: - import resource - - # Get current limit - soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE) - - new_limit = min(new_limit, hard_limit) - - # Attempt to set new limit - resource.setrlimit(resource.RLIMIT_NOFILE, (new_limit, hard_limit)) - except Exception as e: - sys.stderr.write(f"Failed to set new ulimit: {e}\n") - - -increase_limit(65535) - - -def flatten_config(config, base="bbot"): - """ - Flatten a JSON-like config into a list of environment variables: - {"modules": [{"httpx": {"timeout": 5}}]} --> "BBOT_MODULES_HTTPX_TIMEOUT=5" - """ - if type(config) == omegaconf.dictconfig.DictConfig: - for k, v in config.items(): - new_base = f"{base}_{k}" - if type(v) == omegaconf.dictconfig.DictConfig: - yield from flatten_config(v, base=new_base) - elif type(v) != omegaconf.listconfig.ListConfig: - yield (new_base.upper(), str(v)) - - -def add_to_path(v, k="PATH"): - var_list = os.environ.get(k, "").split(":") - deduped_var_list = [] - for _ in var_list: - if not _ in deduped_var_list: - deduped_var_list.append(_) - if not v in deduped_var_list: - deduped_var_list = [v] + deduped_var_list - new_var_str = ":".join(deduped_var_list) - os.environ[k] = new_var_str - - -def prepare_environment(bbot_config): - """ - Sync config to OS environment variables - """ - # ensure bbot_home - if not "home" in bbot_config: - bbot_config["home"] = "~/.bbot" - home = Path(bbot_config["home"]).expanduser().resolve() - bbot_config["home"] = str(home) - - # if we're running in a virtual environment, make sure to include its /bin in PATH - if sys.prefix != sys.base_prefix: - bin_dir = str(Path(sys.prefix) / "bin") - add_to_path(bin_dir) - - # add ~/.local/bin to PATH - local_bin_dir = str(Path.home() / ".local" / "bin") - add_to_path(local_bin_dir) - - # ensure bbot_tools - bbot_tools = home / "tools" - os.environ["BBOT_TOOLS"] = str(bbot_tools) - if not str(bbot_tools) in os.environ.get("PATH", "").split(":"): - os.environ["PATH"] = f'{bbot_tools}:{os.environ.get("PATH", "").strip(":")}' - # ensure bbot_cache - bbot_cache = home / "cache" - os.environ["BBOT_CACHE"] = str(bbot_cache) - # ensure bbot_temp - bbot_temp = home / "temp" - os.environ["BBOT_TEMP"] = str(bbot_temp) - # ensure bbot_lib - bbot_lib = home / "lib" - os.environ["BBOT_LIB"] = str(bbot_lib) - # export LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:~/.bbot/lib/ - add_to_path(str(bbot_lib), k="LD_LIBRARY_PATH") - - # platform variables - os.environ["BBOT_OS_PLATFORM"] = os_platform() - os.environ["BBOT_OS"] = os_platform_friendly() - os.environ["BBOT_CPU_ARCH"] = cpu_architecture() - - # exchange certain options between CLI args and config - if cli_execution and args.cli_options is not None: - # deps - bbot_config["retry_deps"] = args.cli_options.retry_deps - bbot_config["force_deps"] = args.cli_options.force_deps - bbot_config["no_deps"] = args.cli_options.no_deps - bbot_config["ignore_failed_deps"] = args.cli_options.ignore_failed_deps - # debug - bbot_config["debug"] = args.cli_options.debug - bbot_config["silent"] = args.cli_options.silent - - import logging - - log = logging.getLogger() - if bbot_config.get("debug", False): - bbot_config["silent"] = False - log = logging.getLogger("bbot") - log.setLevel(logging.DEBUG) - logging.getLogger("asyncio").setLevel(logging.DEBUG) - elif bbot_config.get("silent", False): - log = logging.getLogger("bbot") - log.setLevel(logging.CRITICAL) - - # copy config to environment - bbot_environ = flatten_config(bbot_config) - os.environ.update(bbot_environ) - - # handle HTTP proxy - http_proxy = bbot_config.get("http_proxy", "") - if http_proxy: - os.environ["HTTP_PROXY"] = http_proxy - os.environ["HTTPS_PROXY"] = http_proxy - else: - os.environ.pop("HTTP_PROXY", None) - os.environ.pop("HTTPS_PROXY", None) - - # replace environment variables in preloaded modules - module_loader.find_and_replace(**os.environ) - - # ssl verification - import urllib3 - - urllib3.disable_warnings() - ssl_verify = bbot_config.get("ssl_verify", False) - if not ssl_verify: - import requests - import functools - - requests.adapters.BaseAdapter.send = functools.partialmethod(requests.adapters.BaseAdapter.send, verify=False) - requests.adapters.HTTPAdapter.send = functools.partialmethod(requests.adapters.HTTPAdapter.send, verify=False) - requests.Session.request = functools.partialmethod(requests.Session.request, verify=False) - requests.request = functools.partial(requests.request, verify=False) - - return bbot_config diff --git a/bbot/core/configurator/files.py b/bbot/core/configurator/files.py deleted file mode 100644 index e56950597..000000000 --- a/bbot/core/configurator/files.py +++ /dev/null @@ -1,40 +0,0 @@ -import sys -from pathlib import Path -from omegaconf import OmegaConf - -from ..helpers.misc import mkdir -from ..errors import ConfigLoadError -from ..helpers.logger import log_to_stderr - -config_dir = (Path.home() / ".config" / "bbot").resolve() -defaults_filename = (Path(__file__).parent.parent.parent / "defaults.yml").resolve() -mkdir(config_dir) -config_filename = (config_dir / "bbot.yml").resolve() -secrets_filename = (config_dir / "secrets.yml").resolve() -default_config = None - - -def _get_config(filename, name="config"): - notify = False - if sys.argv and sys.argv[0].endswith("bbot") and not any(x in sys.argv for x in ("-s", "--silent")): - notify = True - filename = Path(filename).resolve() - try: - conf = OmegaConf.load(str(filename)) - if notify and __name__ == "__main__": - log_to_stderr(f"Loaded {name} from {filename}") - return conf - except Exception as e: - if filename.exists(): - raise ConfigLoadError(f"Error parsing config at {filename}:\n\n{e}") - return OmegaConf.create() - - -def get_config(): - global default_config - default_config = _get_config(defaults_filename, name="defaults") - return OmegaConf.merge( - default_config, - _get_config(config_filename, name="config"), - _get_config(secrets_filename, name="secrets"), - ) diff --git a/bbot/core/core.py b/bbot/core/core.py new file mode 100644 index 000000000..e7eacf18d --- /dev/null +++ b/bbot/core/core.py @@ -0,0 +1,213 @@ +import os +import logging +from copy import copy +from pathlib import Path +from contextlib import suppress +from omegaconf import OmegaConf + +from bbot.errors import BBOTError + + +DEFAULT_CONFIG = None + + +class BBOTCore: + """ + This is the first thing that loads when you import BBOT. + + Unlike a Preset, BBOTCore holds only the config, not scan-specific stuff like targets, flags, modules, etc. + + Its main jobs are: + + - set up logging + - keep separation between the `default` and `custom` config (this allows presets to only display the config options that have changed) + - allow for easy merging of configs + - load quickly + """ + + # used for filtering out sensitive config values + secrets_strings = ["api_key", "username", "password", "token", "secret", "_id"] + # don't filter/remove entries under this key + secrets_exclude_keys = ["modules"] + + def __init__(self): + self._logger = None + self._files_config = None + + self.bbot_sudo_pass = None + + self._config = None + self._custom_config = None + + # bare minimum == logging + self.logger + self.log = logging.getLogger("bbot.core") + + import multiprocessing + + self.process_name = multiprocessing.current_process().name + + @property + def home(self): + return Path(self.config["home"]).expanduser().resolve() + + @property + def cache_dir(self): + return self.home / "cache" + + @property + def tools_dir(self): + return self.home / "tools" + + @property + def temp_dir(self): + return self.home / "temp" + + @property + def lib_dir(self): + return self.home / "lib" + + @property + def scans_dir(self): + return self.home / "scans" + + @property + def config(self): + """ + .config is just .default_config + .custom_config merged together + + any new values should be added to custom_config. + """ + if self._config is None: + self._config = OmegaConf.merge(self.default_config, self.custom_config) + # set read-only flag (change .custom_config instead) + OmegaConf.set_readonly(self._config, True) + return self._config + + @property + def default_config(self): + """ + The default BBOT config (from `defaults.yml`). Read-only. + """ + global DEFAULT_CONFIG + if DEFAULT_CONFIG is None: + self.default_config = self.files_config.get_default_config() + # ensure bbot home dir + if not "home" in self.default_config: + self.default_config["home"] = "~/.bbot" + return DEFAULT_CONFIG + + @default_config.setter + def default_config(self, value): + # we temporarily clear out the config so it can be refreshed if/when default_config changes + global DEFAULT_CONFIG + self._config = None + DEFAULT_CONFIG = value + # set read-only flag (change .custom_config instead) + OmegaConf.set_readonly(DEFAULT_CONFIG, True) + + @property + def custom_config(self): + """ + Custom BBOT config (from `~/.config/bbot/bbot.yml`) + """ + # we temporarily clear out the config so it can be refreshed if/when custom_config changes + self._config = None + if self._custom_config is None: + self.custom_config = self.files_config.get_custom_config() + return self._custom_config + + @custom_config.setter + def custom_config(self, value): + # we temporarily clear out the config so it can be refreshed if/when custom_config changes + self._config = None + # ensure the modules key is always a dictionary + modules_entry = value.get("modules", None) + if modules_entry is not None and not OmegaConf.is_dict(modules_entry): + value["modules"] = {} + self._custom_config = value + + def no_secrets_config(self, config): + from .helpers.misc import clean_dict + + with suppress(ValueError): + config = OmegaConf.to_object(config) + + return clean_dict( + config, + *self.secrets_strings, + fuzzy=True, + exclude_keys=self.secrets_exclude_keys, + ) + + def secrets_only_config(self, config): + from .helpers.misc import filter_dict + + with suppress(ValueError): + config = OmegaConf.to_object(config) + + return filter_dict( + config, + *self.secrets_strings, + fuzzy=True, + exclude_keys=self.secrets_exclude_keys, + ) + + def merge_custom(self, config): + """ + Merge a config into the custom config. + """ + self.custom_config = OmegaConf.merge(self.custom_config, OmegaConf.create(config)) + + def merge_default(self, config): + """ + Merge a config into the default config. + """ + self.default_config = OmegaConf.merge(self.default_config, OmegaConf.create(config)) + + def copy(self): + """ + Return a semi-shallow copy of self. (`custom_config` is copied, but `default_config` stays the same) + """ + core_copy = copy(self) + core_copy._custom_config = self._custom_config.copy() + return core_copy + + @property + def files_config(self): + """ + Get the configs from `bbot.yml` and `defaults.yml` + """ + if self._files_config is None: + from .config import files + + self.files = files + self._files_config = files.BBOTConfigFiles(self) + return self._files_config + + def create_process(self, *args, **kwargs): + if os.environ.get("BBOT_TESTING", "") == "True": + process = self.create_thread(*args, **kwargs) + else: + if self.process_name == "MainProcess": + from .helpers.process import BBOTProcess + + process = BBOTProcess(*args, **kwargs) + else: + raise BBOTError(f"Tried to start server from process {self.process_name}") + process.daemon = True + return process + + def create_thread(self, *args, **kwargs): + from .helpers.process import BBOTThread + + return BBOTThread(*args, **kwargs) + + @property + def logger(self): + self.config + if self._logger is None: + from .config.logger import BBOTLogger + + self._logger = BBOTLogger(self) + return self._logger diff --git a/bbot/core/engine.py b/bbot/core/engine.py new file mode 100644 index 000000000..7f0b131d1 --- /dev/null +++ b/bbot/core/engine.py @@ -0,0 +1,434 @@ +import zmq +import pickle +import asyncio +import inspect +import logging +import tempfile +import traceback +import zmq.asyncio +from pathlib import Path +from contextlib import asynccontextmanager, suppress + +from bbot.core import CORE +from bbot.errors import BBOTEngineError +from bbot.core.helpers.misc import rand_string + + +error_sentinel = object() + + +class EngineBase: + """ + Base Engine class for Server and Client. + + An Engine is a simple and lightweight RPC implementation that allows offloading async tasks + to a separate process. It leverages ZeroMQ in a ROUTER-DEALER configuration. + + BBOT makes use of this by spawning a dedicated engine for DNS and HTTP tasks. + This offloads I/O and helps free up the main event loop for other tasks. + + To use of Engine, you must subclass both EngineClient and EngineServer. + + See the respective EngineClient and EngineServer classes for usage examples. + """ + + ERROR_CLASS = BBOTEngineError + + def __init__(self): + self.log = logging.getLogger(f"bbot.core.{self.__class__.__name__.lower()}") + + def pickle(self, obj): + try: + return pickle.dumps(obj) + except Exception as e: + self.log.error(f"Error serializing object: {obj}: {e}") + self.log.trace(traceback.format_exc()) + return error_sentinel + + def unpickle(self, binary): + try: + return pickle.loads(binary) + except Exception as e: + self.log.error(f"Error deserializing binary: {e}") + self.log.trace(f"Offending binary: {binary}") + self.log.trace(traceback.format_exc()) + return error_sentinel + + +class EngineClient(EngineBase): + """ + The client portion of BBOT's RPC Engine. + + To create an engine, you must create a subclass of this class and also + define methods for each of your desired functions. + + Note that this only supports async functions. If you need to offload a synchronous function to another CPU, use BBOT's multiprocessing pool instead. + + Any CPU or I/O intense logic should be implemented in the EngineServer. + + These functions are typically stubs whose only job is to forward the arguments to the server. + + Functions with the same names should be defined on the EngineServer. + + The EngineClient must specify its associated server class via the `SERVER_CLASS` variable. + + Depending on whether your function is a generator, you will use either `run_and_return()`, or `run_and_yield`. + + Examples: + >>> from bbot.core.engine import EngineClient + >>> + >>> class MyClient(EngineClient): + >>> SERVER_CLASS = MyServer + >>> + >>> async def my_function(self, **kwargs) + >>> return await self.run_and_return("my_function", **kwargs) + >>> + >>> async def my_generator(self, **kwargs): + >>> async for _ in self.run_and_yield("my_generator", **kwargs): + >>> yield _ + """ + + SERVER_CLASS = None + + def __init__(self, **kwargs): + self._shutdown = False + super().__init__() + self.name = f"EngineClient {self.__class__.__name__}" + self.process = None + if self.SERVER_CLASS is None: + raise ValueError(f"Must set EngineClient SERVER_CLASS, {self.SERVER_CLASS}") + self.CMDS = dict(self.SERVER_CLASS.CMDS) + for k, v in list(self.CMDS.items()): + self.CMDS[v] = k + self.socket_address = f"zmq_{rand_string(8)}.sock" + self.socket_path = Path(tempfile.gettempdir()) / self.socket_address + self.server_kwargs = kwargs.pop("server_kwargs", {}) + self._server_process = None + self.context = zmq.asyncio.Context() + self.context.setsockopt(zmq.LINGER, 0) + self.sockets = set() + + def check_error(self, message): + if isinstance(message, dict) and len(message) == 1 and "_e" in message: + error, trace = message["_e"] + error = self.ERROR_CLASS(error) + error.engine_traceback = trace + raise error + return False + + async def run_and_return(self, command, *args, **kwargs): + if self._shutdown: + self.log.verbose("Engine has been shut down and is not accepting new tasks") + return + async with self.new_socket() as socket: + try: + message = self.make_message(command, args=args, kwargs=kwargs) + if message is error_sentinel: + return + await socket.send(message) + binary = await socket.recv() + except BaseException: + # -1 == special "cancel" signal + cancel_message = pickle.dumps({"c": -1}) + with suppress(Exception): + await socket.send(cancel_message) + raise + # self.log.debug(f"{self.name}.{command}({kwargs}) got binary: {binary}") + message = self.unpickle(binary) + self.log.debug(f"{self.name}.{command}({kwargs}) got message: {message}") + # error handling + if self.check_error(message): + return + return message + + async def run_and_yield(self, command, *args, **kwargs): + if self._shutdown: + self.log.verbose("Engine has been shut down and is not accepting new tasks") + return + message = self.make_message(command, args=args, kwargs=kwargs) + if message is error_sentinel: + return + async with self.new_socket() as socket: + await socket.send(message) + while 1: + try: + binary = await socket.recv() + # self.log.debug(f"{self.name}.{command}({kwargs}) got binary: {binary}") + message = self.unpickle(binary) + self.log.debug(f"{self.name}.{command}({kwargs}) got message: {message}") + # error handling + if self.check_error(message) or self.check_stop(message): + break + yield message + except GeneratorExit: + # -1 == special "cancel" signal + cancel_message = pickle.dumps({"c": -1}) + with suppress(Exception): + await socket.send(cancel_message) + raise + + def check_stop(self, message): + if isinstance(message, dict) and len(message) == 1 and "_s" in message: + return True + return False + + def make_message(self, command, args=None, kwargs=None): + try: + cmd_id = self.CMDS[command] + except KeyError: + raise KeyError(f'Command "{command}" not found. Available commands: {",".join(self.available_commands)}') + message = {"c": cmd_id} + if args: + message["a"] = args + if kwargs: + message["k"] = kwargs + return pickle.dumps(message) + + @property + def available_commands(self): + return [s for s in self.CMDS if isinstance(s, str)] + + def start_server(self): + import multiprocessing + + process_name = multiprocessing.current_process().name + if process_name == "MainProcess": + self.process = CORE.create_process( + target=self.server_process, + args=( + self.SERVER_CLASS, + self.socket_path, + ), + kwargs=self.server_kwargs, + custom_name=f"BBOT {self.__class__.__name__}", + ) + self.process.start() + return self.process + else: + raise BBOTEngineError( + f"Tried to start server from process {process_name}. Did you forget \"if __name__ == '__main__'?\"" + ) + + @staticmethod + def server_process(server_class, socket_path, **kwargs): + try: + engine_server = server_class(socket_path, **kwargs) + asyncio.run(engine_server.worker()) + except (asyncio.CancelledError, KeyboardInterrupt): + pass + except Exception: + import traceback + + log = logging.getLogger("bbot.core.engine.server") + log.critical(f"Unhandled error in {server_class.__name__} server process: {traceback.format_exc()}") + + @asynccontextmanager + async def new_socket(self): + if self._server_process is None: + self._server_process = self.start_server() + while not self.socket_path.exists(): + await asyncio.sleep(0.1) + socket = self.context.socket(zmq.DEALER) + socket.setsockopt(zmq.LINGER, 0) + socket.connect(f"ipc://{self.socket_path}") + self.sockets.add(socket) + try: + yield socket + finally: + self.sockets.remove(socket) + with suppress(Exception): + socket.close() + + async def shutdown(self): + self._shutdown = True + async with self.new_socket() as socket: + # -99 == special shutdown signal + shutdown_message = pickle.dumps({"c": -99}) + await socket.send(shutdown_message) + for socket in self.sockets: + socket.close() + self.context.term() + # delete socket file on exit + self.socket_path.unlink(missing_ok=True) + + +class EngineServer(EngineBase): + """ + The server portion of BBOT's RPC Engine. + + Methods defined here must match the methods in your EngineClient. + + To use the functions, you must create mappings for them in the CMDS attribute, as shown below. + + Examples: + >>> from bbot.core.engine import EngineServer + >>> + >>> class MyServer(EngineServer): + >>> CMDS = { + >>> 0: "my_function", + >>> 1: "my_generator", + >>> } + >>> + >>> def my_function(self, arg1=None): + >>> await asyncio.sleep(1) + >>> return str(arg1) + >>> + >>> def my_generator(self): + >>> for i in range(10): + >>> await asyncio.sleep(1) + >>> yield i + """ + + CMDS = {} + + def __init__(self, socket_path): + super().__init__() + self.name = f"EngineServer {self.__class__.__name__}" + self.socket_path = socket_path + if self.socket_path is not None: + # create ZeroMQ context + self.context = zmq.asyncio.Context() + self.context.setsockopt(zmq.LINGER, 0) + # ROUTER socket can handle multiple concurrent requests + self.socket = self.context.socket(zmq.ROUTER) + self.socket.setsockopt(zmq.LINGER, 0) + # create socket file + self.socket.bind(f"ipc://{self.socket_path}") + # task <--> client id mapping + self.tasks = dict() + + async def run_and_return(self, client_id, command_fn, *args, **kwargs): + try: + self.log.debug(f"{self.name} run-and-return {command_fn.__name__}({args}, {kwargs})") + try: + result = await command_fn(*args, **kwargs) + except (asyncio.CancelledError, KeyboardInterrupt): + return + except BaseException as e: + error = f"Error in {self.name}.{command_fn.__name__}({args}, {kwargs}): {e}" + trace = traceback.format_exc() + self.log.debug(error) + self.log.debug(trace) + result = {"_e": (error, trace)} + finally: + self.tasks.pop(client_id, None) + await self.send_socket_multipart(client_id, result) + except BaseException as e: + self.log.critical( + f"Unhandled exception in {self.name}.run_and_return({client_id}, {command_fn}, {args}, {kwargs}): {e}" + ) + self.log.critical(traceback.format_exc()) + + async def run_and_yield(self, client_id, command_fn, *args, **kwargs): + try: + self.log.debug(f"{self.name} run-and-yield {command_fn.__name__}({args}, {kwargs})") + try: + async for _ in command_fn(*args, **kwargs): + await self.send_socket_multipart(client_id, _) + await self.send_socket_multipart(client_id, {"_s": None}) + except (asyncio.CancelledError, KeyboardInterrupt): + return + except BaseException as e: + error = f"Error in {self.name}.{command_fn.__name__}({args}, {kwargs}): {e}" + trace = traceback.format_exc() + self.log.debug(error) + self.log.debug(trace) + result = {"_e": (error, trace)} + await self.send_socket_multipart(client_id, result) + finally: + self.tasks.pop(client_id, None) + except BaseException as e: + self.log.critical( + f"Unhandled exception in {self.name}.run_and_yield({client_id}, {command_fn}, {args}, {kwargs}): {e}" + ) + self.log.critical(traceback.format_exc()) + + async def send_socket_multipart(self, client_id, message): + try: + message = pickle.dumps(message) + await self.socket.send_multipart([client_id, message]) + except Exception as e: + self.log.warning(f"Error sending ZMQ message: {e}") + self.log.trace(traceback.format_exc()) + + def check_error(self, message): + if message is error_sentinel: + return True + + async def worker(self): + try: + while 1: + client_id, binary = await self.socket.recv_multipart() + message = self.unpickle(binary) + self.log.debug(f"{self.name} got message: {message}") + if self.check_error(message): + continue + + cmd = message.get("c", None) + if not isinstance(cmd, int): + self.log.warning(f"No command sent in message: {message}") + continue + + # -1 == cancel task + if cmd == -1: + await self.cancel_task(client_id) + continue + + # -99 == shut down engine + if cmd == -99: + self.log.verbose("Got shutdown signal, shutting down...") + await self.cancel_all_tasks() + return + + args = message.get("a", ()) + if not isinstance(args, tuple): + self.log.warning(f"{self.name}: received invalid args of type {type(args)}, should be tuple") + continue + kwargs = message.get("k", {}) + if not isinstance(kwargs, dict): + self.log.warning(f"{self.name}: received invalid kwargs of type {type(kwargs)}, should be dict") + continue + + command_name = self.CMDS[cmd] + command_fn = getattr(self, command_name, None) + + if command_fn is None: + self.log.warning(f'{self.name} has no function named "{command_fn}"') + continue + + if inspect.isasyncgenfunction(command_fn): + coroutine = self.run_and_yield(client_id, command_fn, *args, **kwargs) + else: + coroutine = self.run_and_return(client_id, command_fn, *args, **kwargs) + + task = asyncio.create_task(coroutine) + self.tasks[client_id] = task, command_fn, args, kwargs + except Exception as e: + self.log.error(f"Error in EngineServer worker: {e}") + self.log.trace(traceback.format_exc()) + finally: + self.socket.close() + self.context.term() + # delete socket file on exit + self.socket_path.unlink(missing_ok=True) + + async def cancel_task(self, client_id): + task = self.tasks.get(client_id, None) + if task is None: + return + task, _cmd, _args, _kwargs = task + self.log.debug(f"Cancelling client id {client_id} (task: {task})") + task.cancel() + try: + await task + except (KeyboardInterrupt, asyncio.CancelledError): + pass + except BaseException as e: + self.log.error(f"Unhandled error in {_cmd}({_args}, {_kwargs}): {e}") + self.log.trace(traceback.format_exc()) + finally: + self.tasks.pop(client_id, None) + + async def cancel_all_tasks(self): + for client_id in self.tasks: + await self.cancel_task(client_id) diff --git a/bbot/core/event/__init__.py b/bbot/core/event/__init__.py index e4410fcea..b5d1c8608 100644 --- a/bbot/core/event/__init__.py +++ b/bbot/core/event/__init__.py @@ -1,2 +1 @@ -from .helpers import make_event_id from .base import make_event, is_event, event_from_json diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index c539936f5..c23769aad 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -2,39 +2,41 @@ import re import json import base64 -import asyncio import logging import tarfile +import datetime import ipaddress import traceback from copy import copy +from pathlib import Path from typing import Optional -from datetime import datetime from contextlib import suppress -from urllib.parse import urljoin +from radixtarget import RadixTarget +from urllib.parse import urljoin, parse_qs from pydantic import BaseModel, field_validator -from pathlib import Path + from .helpers import * -from bbot.core.errors import * +from bbot.errors import * from bbot.core.helpers import ( extract_words, - get_file_extension, - host_in_host, is_domain, is_subdomain, is_ip, is_ptr, is_uri, + url_depth, domain_stem, make_netloc, make_ip_type, recursive_decode, + sha1, smart_decode, split_host_port, tagify, validators, + get_file_extension, ) @@ -71,8 +73,8 @@ class BaseEvent: scan (Scanner): The scan object that generated the event. timestamp (datetime.datetime): The time at which the data was discovered. resolved_hosts (list of str): List of hosts to which the event data resolves, applicable for URLs and DNS names. - source (BaseEvent): The source event that led to the discovery of this event. - source_id (str): The `id` attribute of the source event. + parent (BaseEvent): The parent event that led to the discovery of this event. + parent_id (str): The `id` attribute of the parent event. tags (set of str): Descriptive tags for the event, e.g., `mx-record`, `in-scope`. module (BaseModule): The module that discovered the event. module_sequence (str): The sequence of modules that participated in the discovery. @@ -88,7 +90,7 @@ class BaseEvent: "scan": "SCAN:4d786912dbc97be199da13074699c318e2067a7f", "timestamp": 1688526222.723366, "resolved_hosts": ["185.199.108.153"], - "source": "OPEN_TCP_PORT:cf7e6a937b161217eaed99f0c566eae045d094c7", + "parent": "OPEN_TCP_PORT:cf7e6a937b161217eaed99f0c566eae045d094c7", "tags": ["in-scope", "distance-0", "dir", "ip-185-199-108-153", "status-301", "http-title-301-moved-permanently"], "module": "httpx", "module_sequence": "httpx" @@ -99,14 +101,12 @@ 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"] + _always_emit_tags = ["affiliate", "target"] # 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 # Whether this event has been retroactively marked as part of an important discovery chain _graph_important = False - # Exclude from output modules - _omit = False # Disables certain data validations _dummy = False # Data validation, if data is a dictionary @@ -118,7 +118,8 @@ def __init__( self, data, event_type, - source=None, + parent=None, + context=None, module=None, scan=None, scans=None, @@ -137,7 +138,7 @@ def __init__( Attributes: data (str, dict): The primary data for the event. event_type (str, optional): Type of the event, e.g., 'IP_ADDRESS'. - source (BaseEvent, optional): Source event that led to this event's discovery. Defaults to None. + parent (BaseEvent, optional): Parent event that led to this event's discovery. Defaults to None. 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. scans (list of Scan, optional): BBOT Scan objects, used primarily when unserializing an Event from the database. Defaults to None. @@ -148,36 +149,45 @@ def __init__( _internal (Any, optional): If specified, makes the event internal. Defaults to None. Raises: - ValidationError: If either `scan` or `source` are not specified and `_dummy` is False. + ValidationError: If either `scan` or `parent` are not specified and `_dummy` is False. """ self._id = None self._hash = None + self._data = None self.__host = None + self._tags = set() self._port = None + self._omit = False self.__words = None + self._parent = None self._priority = None + self._parent_id = None + self._host_original = None self._module_priority = None self._resolved_hosts = set() + self.dns_children = dict() + self._discovery_context = "" + + # for creating one-off events without enforcing parent requirement + self._dummy = _dummy + self.module = module + self._type = event_type # keep track of whether this event has been recorded by the scan self._stats_recorded = False - self.timestamp = datetime.utcnow() - - self._tags = set() - if tags is not None: - self._tags = set(tagify(s) for s in tags) + if timestamp is not None: + self.timestamp = timestamp + else: + try: + self.timestamp = datetime.datetime.now(datetime.UTC) + except AttributeError: + self.timestamp = datetime.datetime.utcnow() - self._data = None - self._type = event_type self.confidence = int(confidence) - - # for creating one-off events without enforcing source requirement - self._dummy = _dummy self._internal = False - self.module = module # self.scan holds the instantiated scan object (for helpers, etc.) self.scan = scan if (not self.scan) and (not self._dummy): @@ -189,9 +199,6 @@ def __init__( if self.scan: self.scans = list(set([self.scan.id] + self.scans)) - # check type blacklist - self._check_omit() - self._scope_distance = -1 try: @@ -203,23 +210,27 @@ def __init__( if not self.data: raise ValidationError(f'Invalid event data "{data}" for type "{self.type}"') - self._source = None - self._source_id = None - self.source = source - if (not self.source) and (not self._dummy): - raise ValidationError(f"Must specify event source") + self.parent = parent + if (not self.parent) and (not self._dummy): + raise ValidationError(f"Must specify event parent") + + # inherit web spider distance from parent + self.web_spider_distance = getattr(self.parent, "web_spider_distance", 0) + + if tags is not None: + for tag in tags: + self.add_tag(tag) # internal events are not ingested by output modules if not self._dummy: # removed this second part because it was making certain sslcert events internal - if _internal: # or source._internal: + if _internal: # or parent._internal: self.internal = True - # an event indicating whether the event has undergone DNS resolution - self._resolved = asyncio.Event() - - # inherit web spider distance from parent - self.web_spider_distance = getattr(self.source, "web_spider_distance", 0) + if not context: + context = getattr(self.module, "default_discovery_context", "") + if context: + self.discovery_context = context @property def data(self): @@ -236,6 +247,7 @@ def resolved_hosts(self): @data.setter def data(self, data): self._hash = None + self._data_hash = None self._id = None self.__host = None self._port = None @@ -283,18 +295,33 @@ def host(self): E.g. for IP_ADDRESS, it could be an ipaddress.IPv4Address() or IPv6Address() object """ if self.__host is None: - self.__host = self._host() + self.host = self._host() return self.__host + @host.setter + def host(self, host): + if self._host_original is None: + self._host_original = host + self.__host = host + + @property + def host_original(self): + """ + Original host data, in case it was changed due to a wildcard DNS, etc. + """ + if self._host_original is None: + return self.host + return self._host_original + @property def port(self): self.host - if getattr(self, "parsed", None): - if self.parsed.port is not None: - return self.parsed.port - elif self.parsed.scheme == "https": + if getattr(self, "parsed_url", None): + if self.parsed_url.port is not None: + return self.parsed_url.port + elif self.parsed_url.scheme == "https": return 443 - elif self.parsed.scheme == "http": + elif self.parsed_url.scheme == "http": return 80 return self._port @@ -309,6 +336,26 @@ def host_stem(self): else: return f"{self.host}" + @property + def discovery_context(self): + return self._discovery_context + + @discovery_context.setter + def discovery_context(self, context): + try: + self._discovery_context = context.format(module=self.module, event=self) + except Exception as e: + log.warning(f"Error formatting discovery context for {self}: {e} (context: '{context}')") + self._discovery_context = context + + @property + def discovery_path(self): + """ + This event's full discovery context, including those of all its parents + """ + full_event_chain = list(reversed(self.get_parents())) + [self] + return [e.discovery_context for e in full_event_chain if e.type != "SCAN"] + @property def words(self): if self.__words is None: @@ -324,9 +371,11 @@ def tags(self): @tags.setter def tags(self, tags): + self._tags = set() if isinstance(tags, str): tags = (tags,) - self._tags = set(tagify(s) for s in tags) + for tag in tags: + self.add_tag(tag) def add_tag(self, tag): self._tags.add(tagify(tag)) @@ -351,10 +400,22 @@ def quick_emit(self): @property def id(self): + """ + A uniquely identifiable hash of the event from the event type + a SHA1 of its data + """ if self._id is None: - self._id = make_event_id(self.data_id, self.type) + self._id = f"{self.type}:{self.data_hash.hex()}" return self._id + @property + def data_hash(self): + """ + A raw byte hash of the event's data + """ + if self._data_hash is None: + self._data_hash = sha1(self.data_id).digest() + return self._data_hash + @property def scope_distance(self): return self._scope_distance @@ -394,76 +455,105 @@ def scope_distance(self, scope_distance): self.add_tag(f"distance-{new_scope_distance}") self._scope_distance = new_scope_distance # apply recursively to parent events - source_scope_distance = getattr(self.source, "scope_distance", -1) - if source_scope_distance >= 0 and self != self.source: - self.source.scope_distance = scope_distance + 1 + parent_scope_distance = getattr(self.parent, "scope_distance", -1) + if parent_scope_distance >= 0 and self != self.parent: + self.parent.scope_distance = scope_distance + 1 + + @property + def scope_description(self): + """ + Returns a single word describing the scope of the event. + + "in-scope" if the event is in scope, "affiliate" if it's an affiliate, otherwise "distance-{scope_distance}" + """ + if self.scope_distance == 0: + return "in-scope" + elif "affiliate" in self.tags: + return "affiliate" + return f"distance-{self.scope_distance}" @property - def source(self): - return self._source + def parent(self): + return self._parent - @source.setter - def source(self, source): + @parent.setter + def parent(self, parent): """ - Setter for the source attribute, ensuring it's a valid event and updating scope distance. + Setter for the parent attribute, ensuring it's a valid event and updating scope distance. - Sets the source of the event and automatically adjusts the scope distance based on the source event's - scope distance. The scope distance is incremented by 1 if the host of the source event is different + Sets the parent of the event and automatically adjusts the scope distance based on the parent event's + scope distance. The scope distance is incremented by 1 if the host of the parent event is different from the current event's host. Parameters: - source (BaseEvent): The new source event to set. Must be a valid event object. + parent (BaseEvent): The new parent event to set. Must be a valid event object. Note: - If an invalid source is provided and the event is not a dummy, a warning will be logged. + If an invalid parent is provided and the event is not a dummy, a warning will be logged. """ - if is_event(source): - self._source = source - hosts_are_same = self.host and (self.host == source.host) - if source.scope_distance >= 0: - new_scope_distance = int(source.scope_distance) + if is_event(parent): + self._parent = parent + hosts_are_same = self.host and (self.host == parent.host) + if parent.scope_distance >= 0: + new_scope_distance = int(parent.scope_distance) # only increment the scope distance if the host changes if self._scope_distance_increment_same_host or not hosts_are_same: new_scope_distance += 1 self.scope_distance = new_scope_distance # inherit certain tags if hosts_are_same: - for t in source.tags: + for t in parent.tags: if t == "affiliate": self.add_tag("affiliate") elif t.startswith("mutation-"): self.add_tag(t) elif not self._dummy: - log.warning(f"Tried to set invalid source on {self}: (got: {source})") + log.warning(f"Tried to set invalid parent on {self}: (got: {parent})") + + @property + def parent_id(self): + parent_id = getattr(self.get_parent(), "id", None) + if parent_id is not None: + return parent_id + return self._parent_id @property - def source_id(self): - source_id = getattr(self.get_source(), "id", None) - if source_id is not None: - return source_id - return self._source_id + def validators(self): + """ + Depending on whether the scan attribute is accessible, return either a config-aware or non-config-aware validator + + This exists to prevent a chicken-and-egg scenario during the creation of certain events such as URLs, + whose sanitization behavior is different depending on the config. + + However, thanks to this property, validation can still work in the absence of a config. + """ + if self.scan is not None: + return self.scan.helpers.config_aware_validators + return validators - def get_source(self): + def get_parent(self): """ Takes into account events with the _omit flag """ - if getattr(self.source, "_omit", False): - return self.source.get_source() - return self.source + if getattr(self.parent, "_omit", False): + return self.parent.get_parent() + return self.parent - def get_sources(self, omit=False): - sources = [] + def get_parents(self, omit=False): + parents = [] e = self while 1: if omit: - source = e.get_source() + parent = e.get_parent() else: - source = e.source - if e == source: + parent = e.parent + if parent is None: break - sources.append(source) - e = source - return sources + if e == parent: + break + parents.append(parent) + e = parent + return parents def _host(self): return "" @@ -572,7 +662,9 @@ def __contains__(self, other): if self.host == other.host: return True # hostnames and IPs - return host_in_host(other.host, self.host) + radixtarget = RadixTarget() + radixtarget.insert(self.host) + return bool(radixtarget.search(other.host)) return False def json(self, mode="json", siem_friendly=False): @@ -589,11 +681,13 @@ def json(self, mode="json", siem_friendly=False): Returns: dict: JSON-serializable dictionary representation of the event object. """ + # type, ID, scope description j = dict() - for i in ("type", "id"): + for i in ("type", "id", "scope_description"): v = getattr(self, i, "") if v: j.update({i: v}) + # event data data_attr = getattr(self, f"data_{mode}", None) if data_attr is not None: data = data_attr @@ -603,30 +697,44 @@ def json(self, mode="json", siem_friendly=False): j["data"] = {self.type: data} else: j["data"] = data + # host, dns children + if self.host: + j["host"] = str(self.host) + j["resolved_hosts"] = sorted(str(h) for h in self.resolved_hosts) + j["dns_children"] = {k: list(v) for k, v in self.dns_children.items()} + # web spider distance web_spider_distance = getattr(self, "web_spider_distance", None) if web_spider_distance is not None: j["web_spider_distance"] = web_spider_distance + # scope distance j["scope_distance"] = self.scope_distance + # scan if self.scan: j["scan"] = self.scan.id + # timestamp j["timestamp"] = self.timestamp.timestamp() - if self.host: - j["resolved_hosts"] = [str(h) for h in self.resolved_hosts] - source_id = self.source_id - if source_id: - j["source"] = source_id + # parent event + parent_id = self.parent_id + if parent_id: + j["parent"] = parent_id + # tags if self.tags: j.update({"tags": list(self.tags)}) + # parent module if self.module: j.update({"module": str(self.module)}) + # sequence of modules that led to discovery if self.module_sequence: j.update({"module_sequence": str(self.module_sequence)}) + # discovery context + j["discovery_context"] = self.discovery_context + j["discovery_path"] = self.discovery_path # normalize non-primitive python objects for k, v in list(j.items()): if k == "data": continue - if type(v) not in (str, int, float, bool, list, type(None)): + if type(v) not in (str, int, float, bool, list, dict, type(None)): try: j[k] = json.dumps(v, sort_keys=True) except Exception: @@ -653,14 +761,14 @@ def module_sequence(self): """ Get a human-friendly string that represents the sequence of modules responsible for generating this event. - Includes the names of omitted source events to provide a complete view of the module sequence leading to this event. + Includes the names of omitted parent events to provide a complete view of the module sequence leading to this event. Returns: str: The module sequence in human-friendly format. """ module_name = getattr(self.module, "name", "") - if getattr(self.source, "_omit", False): - module_name = f"{self.source.module_sequence}->{module_name}" + if getattr(self.parent, "_omit", False): + module_name = f"{self.parent.module_sequence}->{module_name}" return module_name @property @@ -678,10 +786,10 @@ def module_priority(self, priority): def priority(self): if self._priority is None: timestamp = self.timestamp.timestamp() - if self.source.timestamp == self.timestamp: + if self.parent.timestamp == self.timestamp: self._priority = (timestamp,) else: - self._priority = getattr(self.source, "priority", ()) + (timestamp,) + self._priority = getattr(self.parent, "priority", ()) + (timestamp,) return self._priority @@ -694,13 +802,24 @@ def type(self, val): self._type = val self._hash = None self._id = None - self._check_omit() - def _check_omit(self): - if self.scan is not None: - omit_event_types = self.scan.config.get("omit_event_types", []) - if omit_event_types and self.type in omit_event_types: - self._omit = True + @property + def _host_size(self): + """ + Used for sorting events by their host size, so that parent ones (e.g. IP subnets) come first + """ + if self.host: + if isinstance(self.host, str): + # smaller domains should come first + return len(self.host) + else: + try: + # bigger IP subnets should come first + return -self.host.num_addresses + except AttributeError: + # IP addresses default to 1 + return 1 + return 0 def __iter__(self): """ @@ -741,6 +860,11 @@ def __repr__(self): return str(self) +class SCAN(BaseEvent): + def _data_human(self): + return f"{self.data['name']} ({self.data['id']})" + + class FINISHED(BaseEvent): """ Special signal event to indicate end of scan @@ -748,7 +872,7 @@ class FINISHED(BaseEvent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._priority = (999999999999999999999,) + self._priority = (999999999999999,) class DefaultEvent(BaseEvent): @@ -760,7 +884,7 @@ class DictEvent(BaseEvent): def sanitize_data(self, data): url = data.get("url", "") if url: - self.parsed = validators.validate_url_parsed(url) + self.parsed_url = self.validators.validate_url_parsed(url) return data def _data_load(self, data): @@ -774,7 +898,7 @@ def _host(self): if isinstance(self.data, dict) and "host" in self.data: return make_ip_type(self.data["host"]) else: - parsed = getattr(self, "parsed", None) + parsed = getattr(self, "parsed_url", None) if parsed is not None: return make_ip_type(parsed.hostname) @@ -838,8 +962,8 @@ def __init__(self, *args, **kwargs): ip = ipaddress.ip_address(self.data) self.add_tag(f"ipv{ip.version}") if ip.is_private: - self.add_tag("private") - self.dns_resolve_distance = getattr(self.source, "dns_resolve_distance", 0) + self.add_tag("private-ip") + self.dns_resolve_distance = getattr(self.parent, "dns_resolve_distance", 0) def sanitize_data(self, data): return validators.validate_host(data) @@ -853,14 +977,14 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # prevent runaway DNS entries self.dns_resolve_distance = 0 - source = getattr(self, "source", None) + parent = getattr(self, "parent", None) module = getattr(self, "module", None) module_type = getattr(module, "_type", "") - source_module = getattr(source, "module", None) - source_module_type = getattr(source_module, "_type", "") + parent_module = getattr(parent, "module", None) + parent_module_type = getattr(parent_module, "_type", "") if module_type == "DNS": - self.dns_resolve_distance = getattr(source, "dns_resolve_distance", 0) - if source_module_type == "DNS": + self.dns_resolve_distance = getattr(parent, "dns_resolve_distance", 0) + if parent_module_type == "DNS": self.dns_resolve_distance += 1 # self.add_tag(f"resolve-distance-{self.dns_resolve_distance}") @@ -924,57 +1048,84 @@ class URL_UNVERIFIED(BaseEvent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # increment the web spider distance - if self.type == "URL_UNVERIFIED" and getattr(self.module, "name", "") != "TARGET": - self.web_spider_distance += 1 - self.num_redirects = getattr(self.source, "num_redirects", 0) + self.num_redirects = getattr(self.parent, "num_redirects", 0) + + def _data_id(self): + + data = super()._data_id() + + # remove the querystring for URL/URL_UNVERIFIED events, because we will conditionally add it back in (based on settings) + if self.__class__.__name__.startswith("URL") and self.scan is not None: + prefix = data.split("?")[0] + + # consider spider-danger tag when deduping + if "spider-danger" in self.tags: + prefix += "spider-danger" + + if not self.scan.config.get("url_querystring_remove", True) and self.parsed_url.query: + query_dict = parse_qs(self.parsed_url.query) + if self.scan.config.get("url_querystring_collapse", True): + # Only consider parameter names in dedup (collapse values) + cleaned_query = "|".join(sorted(query_dict.keys())) + else: + # Consider parameter names and values in dedup + cleaned_query = "&".join( + f"{key}={','.join(sorted(values))}" for key, values in sorted(query_dict.items()) + ) + data = f"{prefix}:{self.parsed_url.scheme}:{self.parsed_url.netloc}:{self.parsed_url.path}:{cleaned_query}" + return data def sanitize_data(self, data): - self.parsed = validators.validate_url_parsed(data) + self.parsed_url = self.validators.validate_url_parsed(data) + + # special handling of URL extensions + if self.parsed_url is not None: + url_path = self.parsed_url.path + if url_path: + parsed_path_lower = str(url_path).lower() + extension = get_file_extension(parsed_path_lower) + if extension: + self.url_extension = extension + self.add_tag(f"extension-{extension}") # tag as dir or endpoint - if str(self.parsed.path).endswith("/"): + if str(self.parsed_url.path).endswith("/"): self.add_tag("dir") else: self.add_tag("endpoint") - parsed_path_lower = str(self.parsed.path).lower() - - scan = getattr(self, "scan", None) - url_extension_blacklist = getattr(scan, "url_extension_blacklist", []) - url_extension_httpx_only = getattr(scan, "url_extension_httpx_only", []) + data = self.parsed_url.geturl() + return data - extension = get_file_extension(parsed_path_lower) - if extension: - self.add_tag(f"extension-{extension}") - if extension in url_extension_blacklist: - self.add_tag("blacklisted") - if extension in url_extension_httpx_only: - self.add_tag("httpx-only") - self._omit = True + def add_tag(self, tag): + if tag == "spider-danger": + # increment the web spider distance + if self.type == "URL_UNVERIFIED": + self.web_spider_distance += 1 + if self.is_spider_max: + self.add_tag("spider-max") + super().add_tag(tag) - data = self.parsed.geturl() - return data + @property + def is_spider_max(self): + if self.scan: + depth = url_depth(self.parsed_url) + if (self.web_spider_distance > self.scan.web_spider_distance) or (depth > self.scan.web_spider_depth): + return True + return False def with_port(self): netloc_with_port = make_netloc(self.host, self.port) - return self.parsed._replace(netloc=netloc_with_port) + return self.parsed_url._replace(netloc=netloc_with_port) def _words(self): - first_elem = self.parsed.path.lstrip("/").split("/")[0] + first_elem = self.parsed_url.path.lstrip("/").split("/")[0] if not "." in first_elem: return extract_words(first_elem) return set() def _host(self): - return make_ip_type(self.parsed.hostname) - - def _data_id(self): - # consider spider-danger tag when deduping - data = super()._data_id() - if "spider-danger" in self.tags: - data = "spider-danger" + data - return data + return make_ip_type(self.parsed_url.hostname) @property def http_status(self): @@ -986,16 +1137,19 @@ def http_status(self): class URL(URL_UNVERIFIED): - def sanitize_data(self, data): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self._dummy and not any(t.startswith("status-") for t in self.tags): raise ValidationError( 'Must specify HTTP status tag for URL event, e.g. "status-200". Use URL_UNVERIFIED if the URL is unvisited.' ) - return super().sanitize_data(data) @property def resolved_hosts(self): - return [".".join(i.split("-")[1:]) for i in self.tags if i.startswith("ip-")] + # TODO: remove this when we rip out httpx + return set(".".join(i.split("-")[1:]) for i in self.tags if i.startswith("ip-")) @property def pretty_string(self): @@ -1023,6 +1177,24 @@ class URL_HINT(URL_UNVERIFIED): pass +class WEB_PARAMETER(DictHostEvent): + + def _data_id(self): + # dedupe by url:name:param_type + url = self.data.get("url", "") + name = self.data.get("name", "") + param_type = self.data.get("type", "") + return f"{url}:{name}:{param_type}" + + def _url(self): + return self.data["url"] + + def __str__(self): + max_event_len = 200 + d = str(self.data) + return f'{self.type}("{d[:max_event_len]}{("..." if len(d) > max_event_len else "")}", module={self.module}, tags={self.tags})' + + class EMAIL_ADDRESS(BaseEvent): def sanitize_data(self, data): return validators.validate_email(data) @@ -1040,14 +1212,17 @@ class HTTP_RESPONSE(URL_UNVERIFIED, DictEvent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # count number of consecutive redirects - self.num_redirects = getattr(self.source, "num_redirects", 0) + self.num_redirects = getattr(self.parent, "num_redirects", 0) if str(self.http_status).startswith("3"): self.num_redirects += 1 + def _data_id(self): + return self.data["method"] + "|" + self.data["url"] + def sanitize_data(self, data): url = data.get("url", "") - self.parsed = validators.validate_url_parsed(url) - data["url"] = self.parsed.geturl() + self.parsed_url = self.validators.validate_url_parsed(url) + data["url"] = self.parsed_url.geturl() header_dict = {} for i in data.get("raw_header", "").splitlines(): @@ -1055,7 +1230,11 @@ def sanitize_data(self, data): k, v = i.split(":", 1) k = k.strip().lower() v = v.lstrip() - header_dict[k] = v + if k in header_dict: + header_dict[k].append(v) + else: + header_dict[k] = [v] + data["header-dict"] = header_dict # move URL to the front of the dictionary for visibility data = dict(data) @@ -1095,7 +1274,7 @@ def redirect_location(self): # if there's no scheme (i.e. it's a relative redirect) if not scheme: # then join the location with the current url - location = urljoin(self.parsed.geturl(), location) + location = urljoin(self.parsed_url.geturl(), location) return location @@ -1249,10 +1428,15 @@ class FILESYSTEM(DictPathEvent): pass +class RAW_DNS_RECORD(DictHostEvent): + pass + + def make_event( data, event_type=None, - source=None, + parent=None, + context=None, module=None, scan=None, scans=None, @@ -1271,7 +1455,8 @@ def make_event( Parameters: data (Union[str, dict, BaseEvent]): The primary data for the event or an existing event object. event_type (str, optional): Type of the event, e.g., 'IP_ADDRESS'. Auto-detected if not provided. - source (BaseEvent, optional): Source event leading to this event's discovery. + parent (BaseEvent, optional): Parent event leading to this event's discovery. + context (str, optional): Description of circumstances leading to event's discovery. module (str, optional): Module that discovered the event. scan (Scan, optional): BBOT Scan object associated with the event. scans (List[Scan], optional): Multiple BBOT Scan objects, primarily used for unserialization. @@ -1288,11 +1473,11 @@ def make_event( Examples: If inside a module, e.g. from within its `handle_event()`: - >>> self.make_event("1.2.3.4", source=event) - IP_ADDRESS("1.2.3.4", module=nmap, tags={'ipv4', 'distance-1'}) + >>> self.make_event("1.2.3.4", parent=event) + IP_ADDRESS("1.2.3.4", module=portscan, tags={'ipv4', 'distance-1'}) If you're outside a module but you have a scan object: - >>> scan.make_event("1.2.3.4", source=scan.root_event) + >>> scan.make_event("1.2.3.4", parent=scan.root_event) IP_ADDRESS("1.2.3.4", module=None, tags={'ipv4', 'distance-1'}) If you're outside a scan and just messing around: @@ -1320,8 +1505,10 @@ def make_event( data.scans = scans if module is not None: data.module = module - if source is not None: - data.source = source + if parent is not None: + data.parent = parent + if context is not None: + data.discovery_context = context if internal == True: data.internal = True if tags: @@ -1363,7 +1550,8 @@ def make_event( return event_class( data, event_type=event_type, - source=source, + parent=parent, + context=context, module=module, scan=scan, scans=scans, @@ -1403,6 +1591,7 @@ def event_from_json(j, siem_friendly=False): "scans": j.get("scans", []), "tags": j.get("tags", []), "confidence": j.get("confidence", 5), + "context": j.get("discovery_context", None), "dummy": True, } if siem_friendly: @@ -1415,11 +1604,11 @@ def event_from_json(j, siem_friendly=False): resolved_hosts = j.get("resolved_hosts", []) event._resolved_hosts = set(resolved_hosts) - event.timestamp = datetime.fromtimestamp(j["timestamp"]) + event.timestamp = datetime.datetime.fromtimestamp(j["timestamp"]) event.scope_distance = j["scope_distance"] - source_id = j.get("source", None) - if source_id is not None: - event._source_id = source_id + parent_id = j.get("parent", None) + if parent_id is not None: + event._parent_id = parent_id return event except KeyError as e: raise ValidationError(f"Event missing required field: {e}") diff --git a/bbot/core/event/helpers.py b/bbot/core/event/helpers.py index d3ad3ee78..0e3bd5fcd 100644 --- a/bbot/core/event/helpers.py +++ b/bbot/core/event/helpers.py @@ -2,9 +2,9 @@ import ipaddress from contextlib import suppress -from bbot.core.errors import ValidationError +from bbot.errors import ValidationError from bbot.core.helpers.regexes import event_type_regexes -from bbot.core.helpers import sha1, smart_decode, smart_encode_punycode +from bbot.core.helpers import smart_decode, smart_encode_punycode log = logging.getLogger("bbot.core.event.helpers") @@ -50,7 +50,3 @@ def get_event_type(data): return t, data raise ValidationError(f'Unable to autodetect event type from "{data}"') - - -def make_event_id(data, event_type): - return f"{event_type}:{sha1(data).hexdigest()}" diff --git a/bbot/core/flags.py b/bbot/core/flags.py index c6b675798..f65dbad28 100644 --- a/bbot/core/flags.py +++ b/bbot/core/flags.py @@ -4,6 +4,7 @@ "aggressive": "Generates a large amount of network traffic", "baddns": "Runs all modules from the DNS auditing tool BadDNS", "cloud-enum": "Enumerates cloud resources", + "code-enum": "Find public code repositories and search them for secrets etc.", "deadly": "Highly aggressive", "email-enum": "Enumerates email addresses", "iis-shortnames": "Scans for IIS Shortname vulnerability", @@ -14,7 +15,6 @@ "service-enum": "Identifies protocols running on open ports", "slow": "May take a long time to complete", "social-enum": "Enumerates social media", - "repo-enum": "Enumerates code repositories", "subdomain-enum": "Enumerates subdomains", "subdomain-hijack": "Detects hijackable subdomains", "web-basic": "Basic, non-intrusive web scan functionality", diff --git a/bbot/core/helpers/async_helpers.py b/bbot/core/helpers/async_helpers.py index 8434ccb0f..dcc510ee4 100644 --- a/bbot/core/helpers/async_helpers.py +++ b/bbot/core/helpers/async_helpers.py @@ -2,7 +2,6 @@ import random import asyncio import logging -import threading from datetime import datetime from queue import Queue, Empty from cachetools import LRUCache @@ -118,8 +117,10 @@ def generator(): if is_done: break + from .process import BBOTThread + # Start the event loop in a separate thread - thread = threading.Thread(target=lambda: asyncio.run(runner())) + thread = BBOTThread(target=lambda: asyncio.run(runner()), daemon=True, custom_name="bbot async_to_sync_gen()") thread.start() # Return the generator diff --git a/bbot/core/helpers/bloom.py b/bbot/core/helpers/bloom.py new file mode 100644 index 000000000..357c715c0 --- /dev/null +++ b/bbot/core/helpers/bloom.py @@ -0,0 +1,71 @@ +import os +import mmh3 +import mmap + + +class BloomFilter: + """ + Simple bloom filter implementation capable of rougly 400K lookups/s. + + BBOT uses bloom filters in scenarios like DNS brute-forcing, where it's useful to keep track + of which mutations have been tried so far. + + A 100-megabyte bloom filter (800M bits) can store 10M entries with a .01% false-positive rate. + A python hash is 36 bytes. So if you wanted to store these in a set, this would take up + 36 * 10M * 2 (key+value) == 720 megabytes. So we save rougly 7 times the space. + """ + + def __init__(self, size=8000000): + self.size = size # total bits + self.byte_size = (size + 7) // 8 # calculate byte size needed for the given number of bits + + # Create an anonymous mmap region, compatible with both Windows and Unix + if os.name == "nt": # Windows + # -1 indicates an anonymous memory map in Windows + self.mmap_file = mmap.mmap(-1, self.byte_size) + else: # Unix/Linux + # Use MAP_ANONYMOUS along with MAP_SHARED + self.mmap_file = mmap.mmap(-1, self.byte_size, prot=mmap.PROT_WRITE, flags=mmap.MAP_ANON | mmap.MAP_SHARED) + + self.clear_all_bits() + + def add(self, item): + for hash_value in self._hashes(item): + index = hash_value // 8 + position = hash_value % 8 + current_byte = self.mmap_file[index] + self.mmap_file[index] = current_byte | (1 << position) + + def check(self, item): + for hash_value in self._hashes(item): + index = hash_value // 8 + position = hash_value % 8 + current_byte = self.mmap_file[index] + if not (current_byte & (1 << position)): + return False + return True + + def clear_all_bits(self): + self.mmap_file.seek(0) + # Write zeros across the entire mmap length + self.mmap_file.write(b"\x00" * self.byte_size) + + def _hashes(self, item): + if not isinstance(item, bytes): + if not isinstance(item, str): + item = str(item) + item = item.encode("utf-8") + return [abs(hash(item)) % self.size, abs(mmh3.hash(item)) % self.size, abs(self._fnv1a_hash(item)) % self.size] + + def _fnv1a_hash(self, data): + hash = 0x811C9DC5 # 2166136261 + for byte in data: + hash ^= byte + hash = (hash * 0x01000193) % 2**32 # 16777619 + return hash + + def __del__(self): + self.mmap_file.close() + + def __contains__(self, item): + return self.check(item) diff --git a/bbot/core/helpers/cloud.py b/bbot/core/helpers/cloud.py deleted file mode 100644 index 811ca070c..000000000 --- a/bbot/core/helpers/cloud.py +++ /dev/null @@ -1,104 +0,0 @@ -import asyncio -import logging - -from cloudcheck import cloud_providers - -log = logging.getLogger("bbot.helpers.cloud") - - -class CloudHelper: - def __init__(self, parent_helper): - self.parent_helper = parent_helper - self.providers = cloud_providers - self.dummy_modules = {} - for provider_name in self.providers.providers: - self.dummy_modules[provider_name] = self.parent_helper._make_dummy_module( - f"{provider_name}_cloud", _type="scan" - ) - self._updated = False - self._update_lock = asyncio.Lock() - - def excavate(self, event, s): - """ - Extract buckets, etc. from strings such as an HTTP responses - """ - for provider in self: - provider_name = provider.name.lower() - base_kwargs = {"source": event, "tags": [f"cloud-{provider_name}"], "_provider": provider_name} - for event_type, sigs in provider.signatures.items(): - found = set() - for sig in sigs: - for match in sig.findall(s): - kwargs = dict(base_kwargs) - kwargs["event_type"] = event_type - if not match in found: - found.add(match) - if event_type == "STORAGE_BUCKET": - self.emit_bucket(match, **kwargs) - else: - self.emit_event(**kwargs) - - def speculate(self, event): - """ - Look for DNS_NAMEs that are buckets or other cloud resources - """ - for provider in self: - provider_name = provider.name.lower() - base_kwargs = dict( - source=event, tags=[f"{provider.provider_type}-{provider_name}"], _provider=provider_name - ) - if event.type.startswith("DNS_NAME"): - for event_type, sigs in provider.signatures.items(): - found = set() - for sig in sigs: - match = sig.match(event.data) - if match: - kwargs = dict(base_kwargs) - kwargs["event_type"] = event_type - if not event.data in found: - found.add(event.data) - if event_type == "STORAGE_BUCKET": - self.emit_bucket(match.groups(), **kwargs) - else: - self.emit_event(**kwargs) - - def emit_bucket(self, match, **kwargs): - bucket_name, bucket_domain = match - kwargs["data"] = {"name": bucket_name, "url": f"https://{bucket_name}.{bucket_domain}"} - self.emit_event(**kwargs) - - def emit_event(self, *args, **kwargs): - provider_name = kwargs.pop("_provider") - dummy_module = self.dummy_modules[provider_name] - event = dummy_module.make_event(*args, **kwargs) - if event: - self.parent_helper.scan.manager.queue_event(event) - - async def tag_event(self, event): - """ - Tags an event according to cloud provider - """ - async with self._update_lock: - if not self._updated: - await self.providers.update() - self._updated = True - - if event.host: - for host in [event.host] + list(event.resolved_hosts): - provider_name, provider_type, source = self.providers.check(host) - if provider_name is not None: - provider = self.providers.providers[provider_name.lower()] - event.add_tag(f"{provider_type}-{provider_name.lower()}") - # if its host directly matches this cloud provider's domains - if not self.parent_helper.is_ip(host): - # tag as buckets, etc. - for event_type, sigs in provider.signatures.items(): - for sig in sigs: - if sig.match(host): - event.add_tag(f"{provider_type}-{event_type}") - - def __getitem__(self, item): - return self.providers.providers[item.lower()] - - def __iter__(self): - yield from self.providers diff --git a/bbot/core/helpers/command.py b/bbot/core/helpers/command.py index 59751cbee..7283291fc 100644 --- a/bbot/core/helpers/command.py +++ b/bbot/core/helpers/command.py @@ -38,6 +38,7 @@ async def run(self, *command, check=False, text=True, idle_timeout=None, **kwarg # proc_tracker optionally keeps track of which processes are running under which modules # this allows for graceful SIGINTing of a module's processes in the case when it's killed proc_tracker = kwargs.pop("_proc_tracker", set()) + log_stderr = kwargs.pop("_log_stderr", True) proc, _input, command = await self._spawn_proc(*command, **kwargs) if proc is not None: proc_tracker.add(proc) @@ -66,7 +67,7 @@ async def run(self, *command, check=False, text=True, idle_timeout=None, **kwarg if proc.returncode: if check: raise CalledProcessError(proc.returncode, command, output=stdout, stderr=stderr) - if stderr: + if stderr and log_stderr: command_str = " ".join(command) log.warning(f"Stderr for run({command_str}):\n\t{stderr}") @@ -103,6 +104,7 @@ async def run_live(self, *command, check=False, text=True, idle_timeout=None, ** # proc_tracker optionally keeps track of which processes are running under which modules # this allows for graceful SIGINTing of a module's processes in the case when it's killed proc_tracker = kwargs.pop("_proc_tracker", set()) + log_stderr = kwargs.pop("_log_stderr", True) proc, _input, command = await self._spawn_proc(*command, **kwargs) if proc is not None: proc_tracker.add(proc) @@ -151,7 +153,7 @@ async def run_live(self, *command, check=False, text=True, idle_timeout=None, ** if check: raise CalledProcessError(proc.returncode, command, output=stdout, stderr=stderr) # surface stderr - if stderr: + if stderr and log_stderr: command_str = " ".join(command) log.warning(f"Stderr for run_live({command_str}):\n\t{stderr}") finally: @@ -201,11 +203,13 @@ async def _write_proc_line(proc, chunk): try: proc.stdin.write(smart_encode(chunk) + b"\n") await proc.stdin.drain() + return True except Exception as e: proc_args = [str(s) for s in getattr(proc, "args", [])] command = " ".join(proc_args) log.warning(f"Error writing line to stdin for command: {command}: {e}") log.trace(traceback.format_exc()) + return False async def _write_stdin(proc, _input): @@ -225,10 +229,14 @@ async def _write_stdin(proc, _input): _input = [_input] if isinstance(_input, (list, tuple)): for chunk in _input: - await _write_proc_line(proc, chunk) + write_result = await _write_proc_line(proc, chunk) + if not write_result: + break else: async for chunk in _input: - await _write_proc_line(proc, chunk) + write_result = await _write_proc_line(proc, chunk) + if not write_result: + break proc.stdin.close() diff --git a/bbot/core/helpers/depsinstaller/installer.py b/bbot/core/helpers/depsinstaller/installer.py index 049baef86..f17c96499 100644 --- a/bbot/core/helpers/depsinstaller/installer.py +++ b/bbot/core/helpers/depsinstaller/installer.py @@ -13,8 +13,6 @@ from ansible_runner.interface import run from subprocess import CalledProcessError -from bbot.core import configurator -from bbot.modules import module_loader from ..misc import can_sudo_without_password, os_platform log = logging.getLogger("bbot.core.helpers.depsinstaller") @@ -23,17 +21,20 @@ class DepsInstaller: def __init__(self, parent_helper): self.parent_helper = parent_helper + self.preset = self.parent_helper.preset + self.core = self.preset.core # respect BBOT's http timeout - http_timeout = self.parent_helper.config.get("http_timeout", 30) + self.web_config = self.parent_helper.config.get("web", {}) + http_timeout = self.web_config.get("http_timeout", 30) os.environ["ANSIBLE_TIMEOUT"] = str(http_timeout) self.askpass_filename = "sudo_askpass.py" self._installed_sudo_askpass = False self._sudo_password = os.environ.get("BBOT_SUDO_PASS", None) if self._sudo_password is None: - if configurator.bbot_sudo_pass is not None: - self._sudo_password = configurator.bbot_sudo_pass + if self.core.bbot_sudo_pass is not None: + self._sudo_password = self.core.bbot_sudo_pass elif can_sudo_without_password(): self._sudo_password = "" self.data_dir = self.parent_helper.cache_dir / "depsinstaller" @@ -43,17 +44,12 @@ def __init__(self, parent_helper): self.parent_helper.mkdir(self.command_status) self.setup_status = self.read_setup_status() - self.no_deps = self.parent_helper.config.get("no_deps", False) - self.ansible_debug = True - self.force_deps = self.parent_helper.config.get("force_deps", False) - self.retry_deps = self.parent_helper.config.get("retry_deps", False) - self.ignore_failed_deps = self.parent_helper.config.get("ignore_failed_deps", False) + self.deps_behavior = self.parent_helper.config.get("deps_behavior", "abort_on_failure").lower() + self.ansible_debug = self.core.logger.log_level <= logging.DEBUG self.venv = "" if sys.prefix != sys.base_prefix: self.venv = sys.prefix - self.all_modules_preloaded = module_loader.preloaded() - self.ensure_root_lock = Lock() async def install(self, *modules): @@ -64,7 +60,7 @@ async def install(self, *modules): notified = False for m in modules: # assume success if we're ignoring dependencies - if self.no_deps: + if self.deps_behavior == "disable": succeeded.append(m) continue # abort if module name is unknown @@ -73,6 +69,7 @@ async def install(self, *modules): failed.append(m) continue preloaded = self.all_modules_preloaded[m] + log.debug(f"Installing {m} - Preloaded Deps {preloaded['deps']}") # make a hash of the dependencies and check if it's already been handled # take into consideration whether the venv or bbot home directory changes module_hash = self.parent_helper.sha1( @@ -84,11 +81,15 @@ async def install(self, *modules): success = self.setup_status.get(module_hash, None) dependencies = list(chain(*preloaded["deps"].values())) if len(dependencies) <= 0: - log.debug(f'No setup to do for module "{m}"') + log.debug(f'No dependency work to do for module "{m}"') succeeded.append(m) continue else: - if success is None or (success is False and self.retry_deps) or self.force_deps: + if ( + success is None + or (success is False and self.deps_behavior == "retry_failed") + or self.deps_behavior == "force_install" + ): if not notified: log.hugeinfo(f"Installing module dependencies. Please be patient, this may take a while.") notified = True @@ -98,14 +99,14 @@ async def install(self, *modules): self.ensure_root(f'Module "{m}" needs root privileges to install its dependencies.') success = await self.install_module(m) self.setup_status[module_hash] = success - if success or self.ignore_failed_deps: + if success or self.deps_behavior == "ignore_failed": log.debug(f'Setup succeeded for module "{m}"') succeeded.append(m) else: log.warning(f'Setup failed for module "{m}"') failed.append(m) else: - if success or self.ignore_failed_deps: + if success or self.deps_behavior == "ignore_failed": log.debug( f'Skipping dependency install for module "{m}" because it\'s already done (--force-deps to re-run)' ) @@ -148,6 +149,20 @@ async def install_module(self, module): if deps_pip: success &= await self.pip_install(deps_pip, constraints=deps_pip_constraints) + # shared/common + deps_common = preloaded["deps"]["common"] + if deps_common: + for dep_common in deps_common: + if self.setup_status.get(dep_common, False) == True: + log.debug( + f'Skipping installation of dependency "{dep_common}" for module "{module}" since it is already installed' + ) + continue + ansible_tasks = self.preset.module_loader._shared_deps[dep_common] + result = self.tasks(module, ansible_tasks) + self.setup_status[dep_common] = result + success &= result + return success async def pip_install(self, packages, constraints=None): @@ -310,7 +325,7 @@ def ensure_root(self, message=""): if self.parent_helper.verify_sudo_password(password): log.success("Authentication successful") self._sudo_password = password - configurator.bbot_sudo_pass = password + self.core.bbot_sudo_pass = password else: log.warning("Incorrect password") @@ -336,3 +351,7 @@ def _install_sudo_askpass(self): askpass_dst = self.parent_helper.tools_dir / self.askpass_filename shutil.copy(askpass_src, askpass_dst) askpass_dst.chmod(askpass_dst.stat().st_mode | stat.S_IEXEC) + + @property + def all_modules_preloaded(self): + return self.preset.module_loader.preloaded() diff --git a/bbot/core/helpers/diff.py b/bbot/core/helpers/diff.py index 5df86fc0f..59ee96567 100644 --- a/bbot/core/helpers/diff.py +++ b/bbot/core/helpers/diff.py @@ -3,19 +3,43 @@ from deepdiff import DeepDiff from contextlib import suppress from xml.parsers.expat import ExpatError -from bbot.core.errors import HttpCompareError +from bbot.errors import HttpCompareError log = logging.getLogger("bbot.core.helpers.diff") class HttpCompare: - def __init__(self, baseline_url, parent_helper, method="GET", allow_redirects=False, include_cache_buster=True): + def __init__( + self, + baseline_url, + parent_helper, + method="GET", + data=None, + allow_redirects=False, + include_cache_buster=True, + headers=None, + cookies=None, + timeout=15, + ): self.parent_helper = parent_helper self.baseline_url = baseline_url self.include_cache_buster = include_cache_buster self.method = method + self.data = data self.allow_redirects = allow_redirects self._baselined = False + self.headers = headers + self.cookies = cookies + self.timeout = 15 + + @staticmethod + def merge_dictionaries(headers1, headers2): + if headers2 is None: + return headers1 + else: + merged_headers = headers1.copy() + merged_headers.update(headers2) + return merged_headers async def _baseline(self): if not self._baselined: @@ -25,7 +49,14 @@ async def _baseline(self): else: url_1 = self.baseline_url baseline_1 = await self.parent_helper.request( - url_1, follow_redirects=self.allow_redirects, method=self.method + url_1, + follow_redirects=self.allow_redirects, + method=self.method, + data=self.data, + headers=self.headers, + cookies=self.cookies, + retries=2, + timeout=self.timeout, ) await self.parent_helper.sleep(1) # put random parameters in URL, headers, and cookies @@ -36,10 +67,17 @@ async def _baseline(self): url_2 = self.parent_helper.add_get_params(self.baseline_url, get_params).geturl() baseline_2 = await self.parent_helper.request( url_2, - headers={self.parent_helper.rand_string(6): self.parent_helper.rand_string(6)}, - cookies={self.parent_helper.rand_string(6): self.parent_helper.rand_string(6)}, + headers=self.merge_dictionaries( + {self.parent_helper.rand_string(6): self.parent_helper.rand_string(6)}, self.headers + ), + cookies=self.merge_dictionaries( + {self.parent_helper.rand_string(6): self.parent_helper.rand_string(6)}, self.cookies + ), follow_redirects=self.allow_redirects, method=self.method, + data=self.data, + retries=2, + timeout=self.timeout, ) self.baseline = baseline_1 @@ -79,6 +117,7 @@ async def _baseline(self): "ETag", "X-Pad", "X-Backside-Transport", + "keep-alive", ] ] dynamic_headers = self.compare_headers(baseline_1.headers, baseline_2.headers) @@ -123,7 +162,15 @@ def compare_body(self, content_1, content_2): return False async def compare( - self, subject, headers=None, cookies=None, check_reflection=False, method="GET", allow_redirects=False + self, + subject, + headers=None, + cookies=None, + check_reflection=False, + method="GET", + data=None, + allow_redirects=False, + timeout=None, ): """ Compares a URL with the baseline, with optional headers or cookies added @@ -133,8 +180,12 @@ async def compare( "reason" is the location of the change ("code", "body", "header", or None), and "reflection" is whether the value was reflected in the HTTP response """ + await self._baseline() + if timeout == None: + timeout = self.timeout + reflection = False if self.include_cache_buster: cache_key, cache_value = list(self.gen_cache_buster().items())[0] @@ -142,7 +193,13 @@ async def compare( else: url = subject subject_response = await self.parent_helper.request( - url, headers=headers, cookies=cookies, follow_redirects=allow_redirects, method=method + url, + headers=headers, + cookies=cookies, + follow_redirects=allow_redirects, + method=method, + data=data, + timeout=timeout, ) if subject_response is None: @@ -190,7 +247,7 @@ async def compare( diff_reasons.append("body") if not diff_reasons: - return (True, [], reflection, None) + return (True, [], reflection, subject_response) else: return (False, diff_reasons, reflection, subject_response) diff --git a/bbot/core/helpers/dns.py b/bbot/core/helpers/dns.py deleted file mode 100644 index 63177756f..000000000 --- a/bbot/core/helpers/dns.py +++ /dev/null @@ -1,1022 +0,0 @@ -import dns -import time -import asyncio -import logging -import ipaddress -import traceback -import contextlib -import dns.exception -import dns.asyncresolver -from cachetools import LRUCache -from contextlib import suppress - -from .regexes import dns_name_regex -from bbot.core.helpers.ratelimiter import RateLimiter -from bbot.core.helpers.async_helpers import NamedLock -from bbot.core.errors import ValidationError, DNSError, DNSWildcardBreak -from .misc import is_ip, is_domain, is_dns_name, domain_parents, parent_domain, rand_string, cloudcheck - -log = logging.getLogger("bbot.core.helpers.dns") - - -class BBOTAsyncResolver(dns.asyncresolver.Resolver): - """Custom asynchronous resolver for BBOT with rate limiting. - - This class extends dnspython's async resolver and provides additional support for rate-limiting DNS queries. - The maximum number of queries allowed per second can be customized via BBOT's config. - - Attributes: - _parent_helper: A reference to the instantiated `ConfigAwareHelper` (typically `scan.helpers`). - _dns_rate_limiter (RateLimiter): An instance of the RateLimiter class for DNS query rate-limiting. - - Args: - *args: Positional arguments passed to the base resolver. - **kwargs: Keyword arguments. '_parent_helper' is expected among these to provide configuration data for - rate-limiting. All other keyword arguments are passed to the base resolver. - """ - - def __init__(self, *args, **kwargs): - self._parent_helper = kwargs.pop("_parent_helper") - dns_queries_per_second = self._parent_helper.config.get("dns_queries_per_second", 100) - self._dns_rate_limiter = RateLimiter(dns_queries_per_second, "DNS") - super().__init__(*args, **kwargs) - self.rotate = True - - async def resolve(self, *args, **kwargs): - async with self._dns_rate_limiter: - return await super().resolve(*args, **kwargs) - - -class DNSHelper: - """Helper class for DNS-related operations within BBOT. - - This class provides mechanisms for host resolution, wildcard domain detection, event tagging, and more. - It centralizes all DNS-related activities in BBOT, offering both synchronous and asynchronous methods - for DNS resolution, as well as various utilities for batch resolution and DNS query filtering. - - Attributes: - parent_helper: A reference to the instantiated `ConfigAwareHelper` (typically `scan.helpers`). - resolver (BBOTAsyncResolver): An asynchronous DNS resolver tailored for BBOT with rate-limiting capabilities. - timeout (int): The timeout value for DNS queries. Defaults to 5 seconds. - retries (int): The number of retries for failed DNS queries. Defaults to 1. - abort_threshold (int): The threshold for aborting after consecutive failed queries. Defaults to 50. - max_dns_resolve_distance (int): Maximum allowed distance for DNS resolution. Defaults to 4. - all_rdtypes (list): A list of DNS record types to be considered during operations. - wildcard_ignore (tuple): Domains to be ignored during wildcard detection. - wildcard_tests (int): Number of tests to be run for wildcard detection. Defaults to 5. - _wildcard_cache (dict): Cache for wildcard detection results. - _dns_cache (LRUCache): Cache for DNS resolution results, limited in size. - _event_cache (LRUCache): Cache for event resolution results, tags. Limited in size. - resolver_file (Path): File containing system's current resolver nameservers. - filter_bad_ptrs (bool): Whether to filter out DNS names that appear to be auto-generated PTR records. Defaults to True. - - Args: - parent_helper: The parent helper object with configuration details and utilities. - - Raises: - DNSError: If an issue arises when creating the BBOTAsyncResolver instance. - - Examples: - >>> dns_helper = DNSHelper(parent_config) - >>> resolved_host = dns_helper.resolver.resolve("example.com") - """ - - all_rdtypes = ["A", "AAAA", "SRV", "MX", "NS", "SOA", "CNAME", "TXT"] - - def __init__(self, parent_helper): - self.parent_helper = parent_helper - try: - self.resolver = BBOTAsyncResolver(_parent_helper=self.parent_helper) - except Exception as e: - raise DNSError(f"Failed to create BBOT DNS resolver: {e}") - self.timeout = self.parent_helper.config.get("dns_timeout", 5) - self.retries = self.parent_helper.config.get("dns_retries", 1) - self.abort_threshold = self.parent_helper.config.get("dns_abort_threshold", 50) - self.max_dns_resolve_distance = self.parent_helper.config.get("max_dns_resolve_distance", 5) - self.resolver.timeout = self.timeout - self.resolver.lifetime = self.timeout - - # skip certain queries - dns_omit_queries = self.parent_helper.config.get("dns_omit_queries", None) - if not dns_omit_queries: - dns_omit_queries = [] - self.dns_omit_queries = dict() - for d in dns_omit_queries: - d = d.split(":") - if len(d) == 2: - rdtype, query = d - rdtype = rdtype.upper() - query = query.lower() - try: - self.dns_omit_queries[rdtype].add(query) - except KeyError: - self.dns_omit_queries[rdtype] = {query} - - self.wildcard_ignore = self.parent_helper.config.get("dns_wildcard_ignore", None) - if not self.wildcard_ignore: - self.wildcard_ignore = [] - self.wildcard_ignore = tuple([str(d).strip().lower() for d in self.wildcard_ignore]) - self.wildcard_tests = self.parent_helper.config.get("dns_wildcard_tests", 5) - self._wildcard_cache = dict() - # since wildcard detection takes some time, This is to prevent multiple - # modules from kicking off wildcard detection for the same domain at the same time - self._wildcard_lock = NamedLock() - self._dns_connectivity_lock = asyncio.Lock() - self._last_dns_success = None - self._last_connectivity_warning = time.time() - # keeps track of warnings issued for wildcard detection to prevent duplicate warnings - self._dns_warnings = set() - self._errors = dict() - self.fallback_nameservers_file = self.parent_helper.wordlist_dir / "nameservers.txt" - self._debug = self.parent_helper.config.get("dns_debug", False) - self._dummy_modules = dict() - self._dns_cache = LRUCache(maxsize=10000) - self._event_cache = LRUCache(maxsize=10000) - self._event_cache_locks = NamedLock() - - # copy the system's current resolvers to a text file for tool use - self.system_resolvers = dns.resolver.Resolver().nameservers - if len(self.system_resolvers) == 1: - log.warning("BBOT performs better with multiple DNS servers. Your system currently only has one.") - self.resolver_file = self.parent_helper.tempfile(self.system_resolvers, pipe=False) - - self.filter_bad_ptrs = self.parent_helper.config.get("dns_filter_ptrs", True) - - async def resolve(self, query, **kwargs): - """Resolve DNS names and IP addresses to their corresponding results. - - This is a high-level function that can translate a given domain name to its associated IP addresses - or an IP address to its corresponding domain names. It's structured for ease of use within modules - and will abstract away most of the complexity of DNS resolution, returning a simple set of results. - - Args: - query (str): The domain name or IP address to resolve. - **kwargs: Additional arguments to be passed to the resolution process. - - Returns: - set: A set containing resolved domain names or IP addresses. - - Examples: - >>> results = await resolve("1.2.3.4") - {"evilcorp.com"} - - >>> results = await resolve("evilcorp.com") - {"1.2.3.4", "dead::beef"} - """ - results = set() - try: - r = await self.resolve_raw(query, **kwargs) - if r: - raw_results, errors = r - for rdtype, answers in raw_results: - for answer in answers: - for _, t in self.extract_targets(answer): - results.add(t) - except BaseException: - log.trace(f"Caught exception in resolve({query}, {kwargs}):") - log.trace(traceback.format_exc()) - raise - - self.debug(f"Results for {query} with kwargs={kwargs}: {results}") - return results - - async def resolve_raw(self, query, **kwargs): - """Resolves the given query to its associated DNS records. - - This function is a foundational method for DNS resolution in this class. It understands both IP addresses and - hostnames and returns their associated records in a raw format provided by the dnspython library. - - Args: - query (str): The IP address or hostname to resolve. - type (str or list[str], optional): Specifies the DNS record type(s) to fetch. Can be a single type like 'A' - or a list like ['A', 'AAAA']. If set to 'any', 'all', or '*', it fetches all supported types. If not - specified, the function defaults to fetching 'A' and 'AAAA' records. - **kwargs: Additional arguments that might be passed to the resolver. - - Returns: - tuple: A tuple containing two lists: - - list: A list of tuples where each tuple consists of a record type string (like 'A') and the associated - raw dnspython answer. - - list: A list of tuples where each tuple consists of a record type string and the associated error if - there was an issue fetching the record. - - Examples: - >>> await resolve_raw("8.8.8.8") - ([('PTR', )], []) - - >>> await resolve_raw("dns.google") - ([('A', ), ('AAAA', )], []) - """ - # DNS over TCP is more reliable - # But setting this breaks DNS resolution on Ubuntu because systemd-resolve doesn't support TCP - # kwargs["tcp"] = True - results = [] - errors = [] - try: - query = str(query).strip() - if is_ip(query): - kwargs.pop("type", None) - kwargs.pop("rdtype", None) - results, errors = await self._resolve_ip(query, **kwargs) - return [("PTR", results)], [("PTR", e) for e in errors] - else: - types = ["A", "AAAA"] - kwargs.pop("rdtype", None) - if "type" in kwargs: - t = kwargs.pop("type") - types = self._parse_rdtype(t, default=types) - for t in types: - r, e = await self._resolve_hostname(query, rdtype=t, **kwargs) - if r: - results.append((t, r)) - for error in e: - errors.append((t, error)) - except BaseException: - log.trace(f"Caught exception in resolve_raw({query}, {kwargs}):") - log.trace(traceback.format_exc()) - raise - - return (results, errors) - - async def _resolve_hostname(self, query, **kwargs): - """Translate a hostname into its corresponding IP addresses. - - This is the foundational function for converting a domain name into its associated IP addresses. It's designed - for internal use within the class and handles retries, caching, and a variety of error/timeout scenarios. - It also respects certain configurations that might ask to skip certain types of queries. Results are returned - in the default dnspython answer object format. - - Args: - query (str): The hostname to resolve. - rdtype (str, optional): The type of DNS record to query (e.g., 'A', 'AAAA'). Defaults to 'A'. - retries (int, optional): The number of times to retry on failure. Defaults to class-wide `retries`. - use_cache (bool, optional): Whether to check the cache before trying a fresh resolution. Defaults to True. - **kwargs: Additional arguments that might be passed to the resolver. - - Returns: - tuple: A tuple containing: - - list: A list of resolved IP addresses. - - list: A list of errors encountered during the resolution process. - - Examples: - >>> results, errors = await _resolve_hostname("google.com") - (, []) - """ - self.debug(f"Resolving {query} with kwargs={kwargs}") - results = [] - errors = [] - rdtype = kwargs.get("rdtype", "A") - - # skip certain queries if requested - if rdtype in self.dns_omit_queries: - if any(h == query or query.endswith(f".{h}") for h in self.dns_omit_queries[rdtype]): - self.debug(f"Skipping {rdtype}:{query} because it's omitted in the config") - return results, errors - - parent = self.parent_helper.parent_domain(query) - retries = kwargs.pop("retries", self.retries) - use_cache = kwargs.pop("use_cache", True) - tries_left = int(retries) + 1 - parent_hash = hash(f"{parent}:{rdtype}") - dns_cache_hash = hash(f"{query}:{rdtype}") - while tries_left > 0: - try: - if use_cache: - results = self._dns_cache.get(dns_cache_hash, []) - if not results: - error_count = self._errors.get(parent_hash, 0) - if error_count >= self.abort_threshold: - connectivity = await self._connectivity_check() - if connectivity: - log.verbose( - f'Aborting query "{query}" because failed {rdtype} queries for "{parent}" ({error_count:,}) exceeded abort threshold ({self.abort_threshold:,})' - ) - if parent_hash not in self._dns_warnings: - log.verbose( - f'Aborting future {rdtype} queries to "{parent}" because error count ({error_count:,}) exceeded abort threshold ({self.abort_threshold:,})' - ) - self._dns_warnings.add(parent_hash) - return results, errors - results = await self._catch(self.resolver.resolve, query, **kwargs) - if use_cache: - self._dns_cache[dns_cache_hash] = results - if parent_hash in self._errors: - self._errors[parent_hash] = 0 - break - except ( - dns.resolver.NoNameservers, - dns.exception.Timeout, - dns.resolver.LifetimeTimeout, - TimeoutError, - ) as e: - try: - self._errors[parent_hash] += 1 - except KeyError: - self._errors[parent_hash] = 1 - errors.append(e) - # don't retry if we get a SERVFAIL - if isinstance(e, dns.resolver.NoNameservers): - break - tries_left -= 1 - err_msg = ( - f'DNS error or timeout for {rdtype} query "{query}" ({self._errors[parent_hash]:,} so far): {e}' - ) - if tries_left > 0: - retry_num = (retries + 1) - tries_left - self.debug(err_msg) - self.debug(f"Retry (#{retry_num}) resolving {query} with kwargs={kwargs}") - else: - log.verbose(err_msg) - - if results: - self._last_dns_success = time.time() - self.debug(f"Answers for {query} with kwargs={kwargs}: {list(results)}") - - if errors: - self.debug(f"Errors for {query} with kwargs={kwargs}: {errors}") - - return results, errors - - async def _resolve_ip(self, query, **kwargs): - """Translate an IP address into a corresponding DNS name. - - This is the most basic function that will convert an IP address into its associated domain name. It handles - retries, caching, and multiple types of timeout/error scenarios internally. The function is intended for - internal use and should not be directly called by modules without understanding its intricacies. - - Args: - query (str): The IP address to be reverse-resolved. - retries (int, optional): The number of times to retry on failure. Defaults to 0. - use_cache (bool, optional): Whether to check the cache for the result before attempting resolution. Defaults to True. - **kwargs: Additional arguments to be passed to the resolution process. - - Returns: - tuple: A tuple containing: - - list: A list of resolved domain names (in default dnspython answer format). - - list: A list of errors encountered during resolution. - - Examples: - >>> results, errors = await _resolve_ip("8.8.8.8") - (, []) - """ - self.debug(f"Reverse-resolving {query} with kwargs={kwargs}") - retries = kwargs.pop("retries", 0) - use_cache = kwargs.pop("use_cache", True) - tries_left = int(retries) + 1 - results = [] - errors = [] - dns_cache_hash = hash(f"{query}:PTR") - while tries_left > 0: - try: - if use_cache: - results = self._dns_cache.get(dns_cache_hash, []) - if not results: - results = await self._catch(self.resolver.resolve_address, query, **kwargs) - if use_cache: - self._dns_cache[dns_cache_hash] = results - break - except ( - dns.exception.Timeout, - dns.resolver.LifetimeTimeout, - dns.resolver.NoNameservers, - TimeoutError, - ) as e: - errors.append(e) - # don't retry if we get a SERVFAIL - if isinstance(e, dns.resolver.NoNameservers): - self.debug(f"{e} (query={query}, kwargs={kwargs})") - break - else: - tries_left -= 1 - if tries_left > 0: - retry_num = (retries + 2) - tries_left - self.debug(f"Retrying (#{retry_num}) {query} with kwargs={kwargs}") - - if results: - self._last_dns_success = time.time() - - return results, errors - - async def handle_wildcard_event(self, event, children): - """ - Used within BBOT's scan manager to detect and tag DNS wildcard events. - - Wildcards are detected for every major record type. If a wildcard is detected, its data - is overwritten, for example: `_wildcard.evilcorp.com`. - - Args: - event (object): The event to check for wildcards. - children (list): A list of the event's resulting DNS children after resolution. - - Returns: - None: This method modifies the `event` in place and does not return a value. - - Examples: - >>> handle_wildcard_event(event, children) - # The `event` might now have tags like ["wildcard", "a-wildcard", "aaaa-wildcard"] and - # its `data` attribute might be modified to "_wildcard.evilcorp.com" if it was detected - # as a wildcard. - """ - log.debug(f"Entering handle_wildcard_event({event}, children={children})") - try: - event_host = str(event.host) - # wildcard checks - if not is_ip(event.host): - # check if the dns name itself is a wildcard entry - wildcard_rdtypes = await self.is_wildcard(event_host) - for rdtype, (is_wildcard, wildcard_host) in wildcard_rdtypes.items(): - wildcard_tag = "error" - if is_wildcard == True: - event.add_tag("wildcard") - wildcard_tag = "wildcard" - event.add_tag(f"{rdtype.lower()}-{wildcard_tag}") - - # wildcard event modification (www.evilcorp.com --> _wildcard.evilcorp.com) - if not is_ip(event.host) and children: - if wildcard_rdtypes: - # these are the rdtypes that successfully resolve - resolved_rdtypes = set([c.upper() for c in children]) - # 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 - event_is_wildcard = False - if resolved_rdtypes: - event_is_wildcard = all(r in wildcard_rdtypes_set for r in resolved_rdtypes) - - if event_is_wildcard: - if event.type in ("DNS_NAME",) and not "_wildcard" in event.data.split("."): - wildcard_parent = self.parent_helper.parent_domain(event_host) - for rdtype, (_is_wildcard, _parent_domain) in wildcard_rdtypes.items(): - if _is_wildcard: - wildcard_parent = _parent_domain - break - wildcard_data = f"_wildcard.{wildcard_parent}" - if wildcard_data != event.data: - log.debug( - f'Wildcard detected, changing event.data "{event.data}" --> "{wildcard_data}"' - ) - event.data = wildcard_data - # tag wildcard domains for convenience - elif is_domain(event_host) or hash(event_host) in self._wildcard_cache: - event_target = "target" in event.tags - wildcard_domain_results = await self.is_wildcard_domain(event_host, log_info=event_target) - for hostname, wildcard_domain_rdtypes in wildcard_domain_results.items(): - if wildcard_domain_rdtypes: - event.add_tag("wildcard-domain") - for rdtype, ips in wildcard_domain_rdtypes.items(): - event.add_tag(f"{rdtype.lower()}-wildcard-domain") - finally: - log.debug(f"Finished handle_wildcard_event({event}, children={children})") - - async def resolve_event(self, event, minimal=False): - """ - Tag the given event with the appropriate DNS record types and optionally create child - events based on DNS resolutions. - - Args: - event (object): The event to be resolved and tagged. - minimal (bool, optional): If set to True, the function will perform minimal DNS - resolution. Defaults to False. - - Returns: - tuple: A 4-tuple containing the following items: - - event_tags (set): Set of tags for the event. - - event_whitelisted (bool): Whether the event is whitelisted. - - event_blacklisted (bool): Whether the event is blacklisted. - - dns_children (dict): Dictionary containing child events from DNS resolutions. - - Examples: - >>> event = make_event("evilcorp.com") - >>> resolve_event(event) - ({'resolved', 'ns-record', 'a-record',}, False, False, {'A': {IPv4Address('1.2.3.4'), IPv4Address('1.2.3.5')}, 'NS': {'ns1.evilcorp.com'}}) - - Note: - This method does not modify the passed in `event`. Instead, it returns data - that can be used to modify or act upon the `event`. - """ - log.debug(f"Resolving {event}") - event_host = str(event.host) - event_tags = set() - dns_children = dict() - event_whitelisted = False - event_blacklisted = False - - try: - if (not event.host) or (event.type in ("IP_RANGE",)): - return event_tags, event_whitelisted, event_blacklisted, dns_children - - # lock to ensure resolution of the same host doesn't start while we're working here - async with self._event_cache_locks.lock(event_host): - # try to get data from cache - _event_tags, _event_whitelisted, _event_blacklisted, _dns_children = self.event_cache_get(event_host) - event_tags.update(_event_tags) - # if we found it, return it - if _event_whitelisted is not None: - return event_tags, _event_whitelisted, _event_blacklisted, _dns_children - - # then resolve - types = () - if self.parent_helper.is_ip(event.host): - if not minimal: - types = ("PTR",) - else: - if event.type == "DNS_NAME" and not minimal: - types = self.all_rdtypes - else: - types = ("A", "AAAA") - - if types: - for t in types: - resolved_raw, errors = await self.resolve_raw(event_host, type=t, use_cache=True) - for rdtype, e in errors: - if rdtype not in resolved_raw: - event_tags.add(f"{rdtype.lower()}-error") - for rdtype, records in resolved_raw: - rdtype = str(rdtype).upper() - if records: - event_tags.add("resolved") - event_tags.add(f"{rdtype.lower()}-record") - - # whitelisting and blacklisting of IPs - for r in records: - for _, t in self.extract_targets(r): - if t: - ip = self.parent_helper.make_ip_type(t) - - if rdtype in ("A", "AAAA", "CNAME"): - with contextlib.suppress(ValidationError): - if self.parent_helper.is_ip(ip): - if self.parent_helper.scan.whitelisted(ip): - event_whitelisted = True - with contextlib.suppress(ValidationError): - if self.parent_helper.scan.blacklisted(ip): - event_blacklisted = True - - if self.filter_bad_ptrs and rdtype in ("PTR") and self.parent_helper.is_ptr(t): - self.debug(f"Filtering out bad PTR: {t}") - continue - - try: - dns_children[rdtype].add(ip) - except KeyError: - dns_children[rdtype] = {ip} - - # tag with cloud providers - if not self.parent_helper.in_tests: - to_check = set() - if event.type == "IP_ADDRESS": - to_check.add(event.data) - for rdtype, ips in dns_children.items(): - if rdtype in ("A", "AAAA"): - for ip in ips: - to_check.add(ip) - for ip in to_check: - provider, provider_type, subnet = cloudcheck(ip) - if provider: - event_tags.add(f"{provider_type}-{provider}") - - # if needed, mark as unresolved - if not is_ip(event_host) and "resolved" not in event_tags: - event_tags.add("unresolved") - # check for private IPs - for rdtype, ips in dns_children.items(): - for ip in ips: - try: - ip = ipaddress.ip_address(ip) - if ip.is_private: - event_tags.add("private-ip") - except ValueError: - continue - - self._event_cache[event_host] = (event_tags, event_whitelisted, event_blacklisted, dns_children) - - return event_tags, event_whitelisted, event_blacklisted, dns_children - - finally: - log.debug(f"Finished resolving {event}") - - def event_cache_get(self, host): - """ - Retrieves cached event data based on the given host. - - Args: - host (str): The host for which the event data is to be retrieved. - - Returns: - tuple: A 4-tuple containing the following items: - - event_tags (set): Set of tags for the event. - - event_whitelisted (bool or None): Whether the event is whitelisted. Returns None if not found. - - event_blacklisted (bool or None): Whether the event is blacklisted. Returns None if not found. - - dns_children (set): Set containing child events from DNS resolutions. - - Examples: - Assuming an event with host "www.evilcorp.com" has been cached: - - >>> event_cache_get("www.evilcorp.com") - ({"resolved", "a-record"}, False, False, {'1.2.3.4'}) - - Assuming no event with host "www.notincache.com" has been cached: - - >>> event_cache_get("www.notincache.com") - (set(), None, None, set()) - """ - try: - event_tags, event_whitelisted, event_blacklisted, dns_children = self._event_cache[host] - return (event_tags, event_whitelisted, event_blacklisted, dns_children) - except KeyError: - return set(), None, None, set() - - async def resolve_batch(self, queries, **kwargs): - """ - A helper to execute a bunch of DNS requests. - - Args: - queries (list): List of queries to resolve. - **kwargs: Additional keyword arguments to pass to `resolve()`. - - Yields: - tuple: A tuple containing the original query and its resolved value. - - Examples: - >>> import asyncio - >>> async def example_usage(): - ... async for result in resolve_batch(['www.evilcorp.com', 'evilcorp.com']): - ... print(result) - ('www.evilcorp.com', {'1.1.1.1'}) - ('evilcorp.com', {'2.2.2.2'}) - - """ - for q in queries: - yield (q, await self.resolve(q, **kwargs)) - - def extract_targets(self, record): - """ - Extracts hostnames or IP addresses from a given DNS record. - - This method reads the DNS record's type and based on that, extracts the target - hostnames or IP addresses it points to. The type of DNS record - (e.g., "A", "MX", "CNAME", etc.) determines which fields are used for extraction. - - Args: - record (dns.rdata.Rdata): The DNS record to extract information from. - - Returns: - set: A set of tuples, each containing the DNS record type and the extracted value. - - Examples: - >>> from dns.rrset import from_text - >>> record = from_text('www.example.com', 3600, 'IN', 'A', '192.0.2.1') - >>> extract_targets(record[0]) - {('A', '192.0.2.1')} - - >>> record = from_text('example.com', 3600, 'IN', 'MX', '10 mail.example.com.') - >>> extract_targets(record[0]) - {('MX', 'mail.example.com')} - - """ - results = set() - rdtype = str(record.rdtype.name).upper() - if rdtype in ("A", "AAAA", "NS", "CNAME", "PTR"): - results.add((rdtype, self._clean_dns_record(record))) - elif rdtype == "SOA": - results.add((rdtype, self._clean_dns_record(record.mname))) - elif rdtype == "MX": - results.add((rdtype, self._clean_dns_record(record.exchange))) - elif rdtype == "SRV": - results.add((rdtype, self._clean_dns_record(record.target))) - elif rdtype == "TXT": - for s in record.strings: - s = self.parent_helper.smart_decode(s) - for match in dns_name_regex.finditer(s): - start, end = match.span() - host = s[start:end] - results.add((rdtype, host)) - elif rdtype == "NSEC": - results.add((rdtype, self._clean_dns_record(record.next))) - else: - log.warning(f'Unknown DNS record type "{rdtype}"') - return results - - @staticmethod - def _clean_dns_record(record): - """ - Cleans and formats a given DNS record for further processing. - - This static method converts the DNS record to text format if it's not already a string. - It also removes any trailing dots and converts the record to lowercase. - - Args: - record (str or dns.rdata.Rdata): The DNS record to clean. - - Returns: - str: The cleaned and formatted DNS record. - - Examples: - >>> _clean_dns_record('www.evilcorp.com.') - 'www.evilcorp.com' - - >>> from dns.rrset import from_text - >>> record = from_text('www.evilcorp.com', 3600, 'IN', 'A', '1.2.3.4')[0] - >>> _clean_dns_record(record) - '1.2.3.4' - """ - if not isinstance(record, str): - record = str(record.to_text()) - return str(record).rstrip(".").lower() - - async def _catch(self, callback, *args, **kwargs): - """ - Asynchronously catches exceptions thrown during DNS resolution and logs them. - - This method wraps around a given asynchronous callback function to handle different - types of DNS exceptions and general exceptions. It logs the exceptions for debugging - and, in some cases, re-raises them. - - Args: - callback (callable): The asynchronous function to be executed. - *args: Positional arguments to pass to the callback. - **kwargs: Keyword arguments to pass to the callback. - - Returns: - Any: The return value of the callback function, or an empty list if an exception is caught. - - Raises: - dns.resolver.NoNameservers: When no nameservers could be reached. - """ - try: - return await callback(*args, **kwargs) - except dns.resolver.NoNameservers: - raise - except (dns.exception.Timeout, dns.resolver.LifetimeTimeout, TimeoutError): - log.debug(f"DNS query with args={args}, kwargs={kwargs} timed out after {self.timeout} seconds") - raise - except dns.exception.DNSException as e: - self.debug(f"{e} (args={args}, kwargs={kwargs})") - except Exception as e: - log.warning(f"Error in {callback.__qualname__}() with args={args}, kwargs={kwargs}: {e}") - log.trace(traceback.format_exc()) - return [] - - async def is_wildcard(self, query, ips=None, rdtype=None): - """ - Use this method to check whether a *host* is a wildcard entry - - This can reliably tell the difference between a valid DNS record and a wildcard within a wildcard domain. - - If you want to know whether a domain is using wildcard DNS, use `is_wildcard_domain()` instead. - - Args: - query (str): The hostname to check for a wildcard entry. - ips (list, optional): List of IPs to compare against, typically obtained from a previous DNS resolution of the query. - rdtype (str, optional): The DNS record type (e.g., "A", "AAAA") to consider during the check. - - Returns: - dict: A dictionary indicating if the query is a wildcard for each checked DNS record type. - Keys are DNS record types like "A", "AAAA", etc. - Values are tuples where the first element is a boolean indicating if the query is a wildcard, - and the second element is the wildcard parent if it's a wildcard. - - Raises: - ValueError: If only one of `ips` or `rdtype` is specified or if no valid IPs are specified. - - Examples: - >>> is_wildcard("www.github.io") - {"A": (True, "github.io"), "AAAA": (True, "github.io")} - - >>> is_wildcard("www.evilcorp.com", ips=["93.184.216.34"], rdtype="A") - {"A": (False, "evilcorp.com")} - - Note: - `is_wildcard` can be True, False, or None (indicating that wildcard detection was inconclusive) - """ - result = {} - - if [ips, rdtype].count(None) == 1: - raise ValueError("Both ips and rdtype must be specified") - - if not is_dns_name(query): - return {} - - # skip check if the query's parent domain is excluded in the config - for d in self.wildcard_ignore: - if self.parent_helper.host_in_host(query, d): - log.debug(f"Skipping wildcard detection on {query} because it is excluded in the config") - return {} - - query = self._clean_dns_record(query) - # skip check if it's an IP - if is_ip(query) or not "." in query: - return {} - # skip check if the query is a domain - if is_domain(query): - return {} - - parent = parent_domain(query) - parents = list(domain_parents(query)) - - rdtypes_to_check = [rdtype] if rdtype is not None else self.all_rdtypes - - base_query_ips = dict() - # if the caller hasn't already done the work of resolving the IPs - if ips is None: - # then resolve the query for all rdtypes - for t in rdtypes_to_check: - raw_results, errors = await self.resolve_raw(query, type=t, use_cache=True) - if errors and not raw_results: - self.debug(f"Failed to resolve {query} ({t}) during wildcard detection") - result[t] = (None, parent) - continue - for __rdtype, answers in raw_results: - base_query_results = set() - for answer in answers: - for _, t in self.extract_targets(answer): - base_query_results.add(t) - if base_query_results: - base_query_ips[__rdtype] = base_query_results - else: - # otherwise, we can skip all that - cleaned_ips = set([self._clean_dns_record(ip) for ip in ips]) - if not cleaned_ips: - raise ValueError("Valid IPs must be specified") - base_query_ips[rdtype] = cleaned_ips - if not base_query_ips: - return result - - # once we've resolved the base query and have IP addresses to work with - # we can compare the IPs to the ones we have on file for wildcards - - # for every parent domain, starting with the shortest - try: - for host in parents[::-1]: - # make sure we've checked that domain for wildcards - await self.is_wildcard_domain(host) - - # for every rdtype - for _rdtype in list(base_query_ips): - # get the IPs from above - query_ips = base_query_ips.get(_rdtype, set()) - host_hash = hash(host) - - if host_hash in self._wildcard_cache: - # then get its IPs from our wildcard cache - wildcard_rdtypes = self._wildcard_cache[host_hash] - - # then check to see if our IPs match the wildcard ones - if _rdtype in wildcard_rdtypes: - wildcard_ips = wildcard_rdtypes[_rdtype] - # if our IPs match the wildcard ones, then ladies and gentlemen we have a wildcard - is_wildcard = any(r in wildcard_ips for r in query_ips) - - if is_wildcard and not result.get(_rdtype, (None, None))[0] is True: - result[_rdtype] = (True, host) - - # if we've reached a point where the dns name is a complete wildcard, class can be dismissed early - base_query_rdtypes = set(base_query_ips) - wildcard_rdtypes_set = set([k for k, v in result.items() if v[0] is True]) - if base_query_rdtypes and wildcard_rdtypes_set and base_query_rdtypes == wildcard_rdtypes_set: - log.debug( - f"Breaking from wildcard detection for {query} at {host} because base query rdtypes ({base_query_rdtypes}) == wildcard rdtypes ({wildcard_rdtypes_set})" - ) - raise DNSWildcardBreak() - except DNSWildcardBreak: - pass - - return result - - async def is_wildcard_domain(self, domain, log_info=False): - """ - Check whether a given host or its children make use of wildcard DNS entries. Wildcard DNS can have - various implications, particularly in subdomain enumeration and subdomain takeovers. - - Args: - domain (str): The domain to check for wildcard DNS entries. - log_info (bool, optional): Whether to log the result of the check. Defaults to False. - - Returns: - dict: A dictionary where the keys are the parent domains that have wildcard DNS entries, - and the values are another dictionary of DNS record types ("A", "AAAA", etc.) mapped to - sets of their resolved IP addresses. - - Examples: - >>> is_wildcard_domain("github.io") - {"github.io": {"A": {"1.2.3.4"}, "AAAA": {"dead::beef"}}} - - >>> is_wildcard_domain("example.com") - {} - """ - wildcard_domain_results = {} - domain = self._clean_dns_record(domain) - - if not is_dns_name(domain): - return {} - - # skip check if the query's parent domain is excluded in the config - for d in self.wildcard_ignore: - if self.parent_helper.host_in_host(domain, d): - log.debug(f"Skipping wildcard detection on {domain} because it is excluded in the config") - return {} - - rdtypes_to_check = set(self.all_rdtypes) - - # make a list of its parents - parents = list(domain_parents(domain, include_self=True)) - # and check each of them, beginning with the highest parent (i.e. the root domain) - for i, host in enumerate(parents[::-1]): - # have we checked this host before? - host_hash = hash(host) - async with self._wildcard_lock.lock(host_hash): - # if we've seen this host before - if host_hash in self._wildcard_cache: - wildcard_domain_results[host] = self._wildcard_cache[host_hash] - continue - - log.verbose(f"Checking if {host} is a wildcard") - - # determine if this is a wildcard domain - - # resolve a bunch of random subdomains of the same parent - is_wildcard = False - wildcard_results = dict() - for rdtype in list(rdtypes_to_check): - # continue if a wildcard was already found for this rdtype - # if rdtype in self._wildcard_cache[host_hash]: - # continue - for _ in range(self.wildcard_tests): - rand_query = f"{rand_string(digits=False, length=10)}.{host}" - results = await self.resolve(rand_query, type=rdtype, use_cache=False) - if results: - is_wildcard = True - if not rdtype in wildcard_results: - wildcard_results[rdtype] = set() - wildcard_results[rdtype].update(results) - # we know this rdtype is a wildcard - # so we don't need to check it anymore - with suppress(KeyError): - rdtypes_to_check.remove(rdtype) - - self._wildcard_cache.update({host_hash: wildcard_results}) - wildcard_domain_results.update({host: wildcard_results}) - if is_wildcard: - wildcard_rdtypes_str = ",".join(sorted([t.upper() for t, r in wildcard_results.items() if r])) - log_fn = log.verbose - if log_info: - log_fn = log.info - log_fn(f"Encountered domain with wildcard DNS ({wildcard_rdtypes_str}): {host}") - else: - log.verbose(f"Finished checking {host}, it is not a wildcard") - - return wildcard_domain_results - - async def _connectivity_check(self, interval=5): - """ - Periodically checks for an active internet connection by attempting DNS resolution. - - Args: - interval (int, optional): The time interval, in seconds, at which to perform the check. - Defaults to 5 seconds. - - Returns: - bool: True if there is an active internet connection, False otherwise. - - Examples: - >>> await _connectivity_check() - True - """ - if self._last_dns_success is not None: - if time.time() - self._last_dns_success < interval: - return True - dns_server_working = [] - async with self._dns_connectivity_lock: - with suppress(Exception): - dns_server_working = await self._catch(self.resolver.resolve, "www.google.com", rdtype="A") - if dns_server_working: - self._last_dns_success = time.time() - return True - if time.time() - self._last_connectivity_warning > interval: - log.warning(f"DNS queries are failing, please check your internet connection") - self._last_connectivity_warning = time.time() - self._errors.clear() - return False - - def _parse_rdtype(self, t, default=None): - if isinstance(t, str): - if t.strip().lower() in ("any", "all", "*"): - return self.all_rdtypes - else: - return [t.strip().upper()] - elif any([isinstance(t, x) for x in (list, tuple)]): - return [str(_).strip().upper() for _ in t] - return default - - def debug(self, *args, **kwargs): - if self._debug: - log.trace(*args, **kwargs) - - def _get_dummy_module(self, name): - try: - dummy_module = self._dummy_modules[name] - except KeyError: - dummy_module = self.parent_helper._make_dummy_module(name=name, _type="DNS") - dummy_module.suppress_dupes = False - self._dummy_modules[name] = dummy_module - return dummy_module diff --git a/bbot/core/helpers/dns/__init__.py b/bbot/core/helpers/dns/__init__.py new file mode 100644 index 000000000..75426cd26 --- /dev/null +++ b/bbot/core/helpers/dns/__init__.py @@ -0,0 +1 @@ +from .dns import DNSHelper diff --git a/bbot/core/helpers/dns/brute.py b/bbot/core/helpers/dns/brute.py new file mode 100644 index 000000000..c34e96610 --- /dev/null +++ b/bbot/core/helpers/dns/brute.py @@ -0,0 +1,180 @@ +import json +import random +import asyncio +import logging +import subprocess + + +class DNSBrute: + """ + Helper for DNS brute-forcing. + + Examples: + >>> domain = "evilcorp.com" + >>> subdomains = ["www", "mail"] + >>> results = await self.helpers.dns.brute(self, domain, subdomains) + """ + + nameservers_url = ( + "https://raw.githubusercontent.com/blacklanternsecurity/public-dns-servers/master/nameservers.txt" + ) + + def __init__(self, parent_helper): + self.parent_helper = parent_helper + self.log = logging.getLogger("bbot.helper.dns.brute") + self.num_canaries = 100 + self.max_resolvers = self.parent_helper.config.get("dns", {}).get("brute_threads", 1000) + self.devops_mutations = list(self.parent_helper.word_cloud.devops_mutations) + self.digit_regex = self.parent_helper.re.compile(r"\d+") + self._resolver_file = None + self._dnsbrute_lock = asyncio.Lock() + + async def __call__(self, *args, **kwargs): + return await self.dnsbrute(*args, **kwargs) + + async def dnsbrute(self, module, domain, subdomains, type=None): + subdomains = list(subdomains) + + if type is None: + type = "A" + type = str(type).strip().upper() + + domain_wildcard_rdtypes = set() + for _domain, rdtypes in (await self.parent_helper.dns.is_wildcard_domain(domain)).items(): + for rdtype, results in rdtypes.items(): + if results: + domain_wildcard_rdtypes.add(rdtype) + if any([r in domain_wildcard_rdtypes for r in (type, "CNAME")]): + self.log.info( + f"Aborting massdns on {domain} because it's a wildcard domain ({','.join(domain_wildcard_rdtypes)})" + ) + return [] + else: + self.log.trace(f"{domain}: A is not in domain_wildcard_rdtypes:{domain_wildcard_rdtypes}") + + canaries = self.gen_random_subdomains(self.num_canaries) + canaries_list = list(canaries) + canaries_pre = canaries_list[: int(self.num_canaries / 2)] + canaries_post = canaries_list[int(self.num_canaries / 2) :] + # sandwich subdomains between canaries + subdomains = canaries_pre + subdomains + canaries_post + + results = [] + canaries_triggered = [] + async for hostname, ip, rdtype in self._massdns(module, domain, subdomains, rdtype=type): + sub = hostname.split(domain)[0] + if sub in canaries: + canaries_triggered.append(sub) + else: + results.append(hostname) + + if len(canaries_triggered) > 5: + self.log.info( + f"Aborting massdns on {domain} due to false positive: ({len(canaries_triggered):,} canaries triggered - {','.join(canaries_triggered)})" + ) + return [] + + # everything checks out + return results + + async def _massdns(self, module, domain, subdomains, rdtype): + """ + { + "name": "www.blacklanternsecurity.com.", + "type": "A", + "class": "IN", + "status": "NOERROR", + "data": { + "answers": [ + { + "ttl": 3600, + "type": "CNAME", + "class": "IN", + "name": "www.blacklanternsecurity.com.", + "data": "blacklanternsecurity.github.io." + }, + { + "ttl": 3600, + "type": "A", + "class": "IN", + "name": "blacklanternsecurity.github.io.", + "data": "185.199.108.153" + } + ] + }, + "resolver": "168.215.165.186:53" + } + """ + resolver_file = await self.resolver_file() + command = ( + "massdns", + "-r", + resolver_file, + "-s", + self.max_resolvers, + "-t", + rdtype, + "-o", + "J", + "-q", + ) + subdomains = self.gen_subdomains(subdomains, domain) + hosts_yielded = set() + async with self._dnsbrute_lock: + async for line in module.run_process_live(*command, stderr=subprocess.DEVNULL, input=subdomains): + try: + j = json.loads(line) + except json.decoder.JSONDecodeError: + self.log.debug(f"Failed to decode line: {line}") + continue + answers = j.get("data", {}).get("answers", []) + if type(answers) == list and len(answers) > 0: + answer = answers[0] + hostname = answer.get("name", "").strip(".").lower() + if hostname.endswith(f".{domain}"): + data = answer.get("data", "") + rdtype = answer.get("type", "").upper() + if data and rdtype: + hostname_hash = hash(hostname) + if hostname_hash not in hosts_yielded: + hosts_yielded.add(hostname_hash) + yield hostname, data, rdtype + + async def gen_subdomains(self, prefixes, domain): + for p in prefixes: + if domain: + p = f"{p}.{domain}" + yield p + + async def resolver_file(self): + if self._resolver_file is None: + self._resolver_file = await self.parent_helper.wordlist( + self.nameservers_url, + cache_hrs=24 * 7, + ) + return self._resolver_file + + def gen_random_subdomains(self, n=50): + delimiters = (".", "-") + lengths = list(range(3, 8)) + for i in range(0, max(0, n - 5)): + d = delimiters[i % len(delimiters)] + l = lengths[i % len(lengths)] + segments = list(random.choice(self.devops_mutations) for _ in range(l)) + segments.append(self.parent_helper.rand_string(length=8, digits=False)) + subdomain = d.join(segments) + yield subdomain + for _ in range(5): + yield self.parent_helper.rand_string(length=8, digits=False) + + def has_excessive_digits(self, d): + """ + Identifies dns names with excessive numbers, e.g.: + - w1-2-3.evilcorp.com + - ptr1234.evilcorp.com + """ + is_ptr = self.parent_helper.is_ptr(d) + digits = self.digit_regex.findall(d) + excessive_digits = len(digits) > 2 + long_digits = any(len(d) > 3 for d in digits) + return is_ptr or excessive_digits or long_digits diff --git a/bbot/core/helpers/dns/dns.py b/bbot/core/helpers/dns/dns.py new file mode 100644 index 000000000..d171036ba --- /dev/null +++ b/bbot/core/helpers/dns/dns.py @@ -0,0 +1,181 @@ +import dns +import logging +import dns.exception +import dns.asyncresolver +from radixtarget import RadixTarget + +from bbot.errors import DNSError +from bbot.core.engine import EngineClient +from ..misc import clean_dns_record, is_ip, is_domain, is_dns_name + +from .engine import DNSEngine + +log = logging.getLogger("bbot.core.helpers.dns") + + +class DNSHelper(EngineClient): + + SERVER_CLASS = DNSEngine + ERROR_CLASS = DNSError + + """Helper class for DNS-related operations within BBOT. + + This class provides mechanisms for host resolution, wildcard domain detection, event tagging, and more. + It centralizes all DNS-related activities in BBOT, offering both synchronous and asynchronous methods + for DNS resolution, as well as various utilities for batch resolution and DNS query filtering. + + Attributes: + parent_helper: A reference to the instantiated `ConfigAwareHelper` (typically `scan.helpers`). + resolver (BBOTAsyncResolver): An asynchronous DNS resolver tailored for BBOT with rate-limiting capabilities. + timeout (int): The timeout value for DNS queries. Defaults to 5 seconds. + retries (int): The number of retries for failed DNS queries. Defaults to 1. + abort_threshold (int): The threshold for aborting after consecutive failed queries. Defaults to 50. + runaway_limit (int): Maximum allowed distance for consecutive DNS resolutions. Defaults to 5. + all_rdtypes (list): A list of DNS record types to be considered during operations. + wildcard_ignore (tuple): Domains to be ignored during wildcard detection. + wildcard_tests (int): Number of tests to be run for wildcard detection. Defaults to 5. + _wildcard_cache (dict): Cache for wildcard detection results. + _dns_cache (LRUCache): Cache for DNS resolution results, limited in size. + resolver_file (Path): File containing system's current resolver nameservers. + filter_bad_ptrs (bool): Whether to filter out DNS names that appear to be auto-generated PTR records. Defaults to True. + + Args: + parent_helper: The parent helper object with configuration details and utilities. + + Raises: + DNSError: If an issue arises when creating the BBOTAsyncResolver instance. + + Examples: + >>> dns_helper = DNSHelper(parent_config) + >>> resolved_host = dns_helper.resolver.resolve("example.com") + """ + + def __init__(self, parent_helper): + self.parent_helper = parent_helper + self.config = self.parent_helper.config + self.dns_config = self.config.get("dns", {}) + super().__init__(server_kwargs={"config": self.config}) + + # resolver + self.timeout = self.dns_config.get("timeout", 5) + self.resolver = dns.asyncresolver.Resolver() + self.resolver.rotate = True + self.resolver.timeout = self.timeout + self.resolver.lifetime = self.timeout + + self.runaway_limit = self.config.get("runaway_limit", 5) + + # wildcard handling + self.wildcard_disable = self.dns_config.get("wildcard_disable", False) + self.wildcard_ignore = RadixTarget() + for d in self.dns_config.get("wildcard_ignore", []): + self.wildcard_ignore.insert(d) + + # copy the system's current resolvers to a text file for tool use + self.system_resolvers = dns.resolver.Resolver().nameservers + # TODO: DNS server speed test (start in background task) + self.resolver_file = self.parent_helper.tempfile(self.system_resolvers, pipe=False) + + # brute force helper + self._brute = None + + async def resolve(self, query, **kwargs): + return await self.run_and_return("resolve", query=query, **kwargs) + + async def resolve_raw(self, query, **kwargs): + return await self.run_and_return("resolve_raw", query=query, **kwargs) + + async def resolve_batch(self, queries, **kwargs): + async for _ in self.run_and_yield("resolve_batch", queries=queries, **kwargs): + yield _ + + async def resolve_raw_batch(self, queries): + async for _ in self.run_and_yield("resolve_raw_batch", queries=queries): + yield _ + + @property + def brute(self): + if self._brute is None: + from .brute import DNSBrute + + self._brute = DNSBrute(self.parent_helper) + return self._brute + + async def is_wildcard(self, query, ips=None, rdtype=None): + """ + Use this method to check whether a *host* is a wildcard entry + + This can reliably tell the difference between a valid DNS record and a wildcard within a wildcard domain. + + If you want to know whether a domain is using wildcard DNS, use `is_wildcard_domain()` instead. + + Args: + query (str): The hostname to check for a wildcard entry. + ips (list, optional): List of IPs to compare against, typically obtained from a previous DNS resolution of the query. + rdtype (str, optional): The DNS record type (e.g., "A", "AAAA") to consider during the check. + + Returns: + dict: A dictionary indicating if the query is a wildcard for each checked DNS record type. + Keys are DNS record types like "A", "AAAA", etc. + Values are tuples where the first element is a boolean indicating if the query is a wildcard, + and the second element is the wildcard parent if it's a wildcard. + + Raises: + ValueError: If only one of `ips` or `rdtype` is specified or if no valid IPs are specified. + + Examples: + >>> is_wildcard("www.github.io") + {"A": (True, "github.io"), "AAAA": (True, "github.io")} + + >>> is_wildcard("www.evilcorp.com", ips=["93.184.216.34"], rdtype="A") + {"A": (False, "evilcorp.com")} + + Note: + `is_wildcard` can be True, False, or None (indicating that wildcard detection was inconclusive) + """ + if [ips, rdtype].count(None) == 1: + raise ValueError("Both ips and rdtype must be specified") + + query = self._wildcard_prevalidation(query) + if not query: + return {} + + # skip check if the query is a domain + if is_domain(query): + return {} + + return await self.run_and_return("is_wildcard", query=query, ips=ips, rdtype=rdtype) + + async def is_wildcard_domain(self, domain, log_info=False): + domain = self._wildcard_prevalidation(domain) + if not domain: + return {} + + return await self.run_and_return("is_wildcard_domain", domain=domain, log_info=False) + + def _wildcard_prevalidation(self, host): + if self.wildcard_disable: + return False + + host = clean_dns_record(host) + # skip check if it's an IP or a plain hostname + if is_ip(host) or not "." in host: + return False + + # skip if query isn't a dns name + if not is_dns_name(host): + return False + + # skip check if the query's parent domain is excluded in the config + wildcard_ignore = self.wildcard_ignore.search(host) + if wildcard_ignore: + log.debug(f"Skipping wildcard detection on {host} because {wildcard_ignore} is excluded in the config") + return False + + return host + + async def _mock_dns(self, mock_data): + from .mock import MockResolver + + self.resolver = MockResolver(mock_data) + await self.run_and_return("_mock_dns", mock_data=mock_data) diff --git a/bbot/core/helpers/dns/engine.py b/bbot/core/helpers/dns/engine.py new file mode 100644 index 000000000..981a0948c --- /dev/null +++ b/bbot/core/helpers/dns/engine.py @@ -0,0 +1,661 @@ +import os +import dns +import time +import asyncio +import logging +import traceback +from cachetools import LRUCache +from contextlib import suppress + +from bbot.errors import DNSWildcardBreak +from bbot.core.engine import EngineServer +from bbot.core.helpers.async_helpers import NamedLock +from bbot.core.helpers.dns.helpers import extract_targets +from bbot.core.helpers.misc import ( + is_ip, + rand_string, + parent_domain, + domain_parents, + clean_dns_record, +) + + +log = logging.getLogger("bbot.core.helpers.dns.engine.server") + +all_rdtypes = ["A", "AAAA", "SRV", "MX", "NS", "SOA", "CNAME", "TXT"] + + +class DNSEngine(EngineServer): + + CMDS = { + 0: "resolve", + 1: "resolve_raw", + 2: "resolve_batch", + 3: "resolve_raw_batch", + 4: "is_wildcard", + 5: "is_wildcard_domain", + 99: "_mock_dns", + } + + def __init__(self, socket_path, config={}): + super().__init__(socket_path) + + self.config = config + self.dns_config = self.config.get("dns", {}) + # config values + self.timeout = self.dns_config.get("timeout", 5) + self.retries = self.dns_config.get("retries", 1) + self.abort_threshold = self.dns_config.get("abort_threshold", 50) + + # resolver + self.resolver = dns.asyncresolver.Resolver() + self.resolver.rotate = True + self.resolver.timeout = self.timeout + self.resolver.lifetime = self.timeout + + # skip certain queries + dns_omit_queries = self.dns_config.get("omit_queries", None) + if not dns_omit_queries: + dns_omit_queries = [] + self.dns_omit_queries = dict() + for d in dns_omit_queries: + d = d.split(":") + if len(d) == 2: + rdtype, query = d + rdtype = rdtype.upper() + query = query.lower() + try: + self.dns_omit_queries[rdtype].add(query) + except KeyError: + self.dns_omit_queries[rdtype] = {query} + + # wildcard handling + self.wildcard_ignore = self.dns_config.get("wildcard_ignore", None) + if not self.wildcard_ignore: + self.wildcard_ignore = [] + self.wildcard_ignore = tuple([str(d).strip().lower() for d in self.wildcard_ignore]) + self.wildcard_tests = self.dns_config.get("wildcard_tests", 5) + self._wildcard_cache = dict() + # since wildcard detection takes some time, This is to prevent multiple + # modules from kicking off wildcard detection for the same domain at the same time + self._wildcard_lock = NamedLock() + + self._dns_connectivity_lock = None + self._last_dns_success = None + self._last_connectivity_warning = time.time() + # keeps track of warnings issued for wildcard detection to prevent duplicate warnings + self._dns_warnings = set() + self._errors = dict() + self._debug = self.dns_config.get("debug", False) + self._dns_cache = LRUCache(maxsize=10000) + + self.filter_bad_ptrs = self.dns_config.get("filter_ptrs", True) + + async def resolve(self, query, **kwargs): + """Resolve DNS names and IP addresses to their corresponding results. + + This is a high-level function that can translate a given domain name to its associated IP addresses + or an IP address to its corresponding domain names. It's structured for ease of use within modules + and will abstract away most of the complexity of DNS resolution, returning a simple set of results. + + Args: + query (str): The domain name or IP address to resolve. + **kwargs: Additional arguments to be passed to the resolution process. + + Returns: + set: A set containing resolved domain names or IP addresses. + + Examples: + >>> results = await resolve("1.2.3.4") + {"evilcorp.com"} + + >>> results = await resolve("evilcorp.com") + {"1.2.3.4", "dead::beef"} + """ + results = set() + try: + answers, errors = await self.resolve_raw(query, **kwargs) + for answer in answers: + for _, host in extract_targets(answer): + results.add(host) + except BaseException: + log.trace(f"Caught exception in resolve({query}, {kwargs}):") + log.trace(traceback.format_exc()) + raise + + self.debug(f"Results for {query} with kwargs={kwargs}: {results}") + return results + + async def resolve_raw(self, query, **kwargs): + """Resolves the given query to its associated DNS records. + + This function is a foundational method for DNS resolution in this class. It understands both IP addresses and + hostnames and returns their associated records in a raw format provided by the dnspython library. + + Args: + query (str): The IP address or hostname to resolve. + type (str or list[str], optional): Specifies the DNS record type(s) to fetch. Can be a single type like 'A' + or a list like ['A', 'AAAA']. If set to 'any', 'all', or '*', it fetches all supported types. If not + specified, the function defaults to fetching 'A' and 'AAAA' records. + **kwargs: Additional arguments that might be passed to the resolver. + + Returns: + tuple: A tuple containing two lists: + - list: A list of tuples where each tuple consists of a record type string (like 'A') and the associated + raw dnspython answer. + - list: A list of tuples where each tuple consists of a record type string and the associated error if + there was an issue fetching the record. + + Examples: + >>> await resolve_raw("8.8.8.8") + ([('PTR', )], []) + + >>> await resolve_raw("dns.google") + (, []) + """ + # DNS over TCP is more reliable + # But setting this breaks DNS resolution on Ubuntu because systemd-resolve doesn't support TCP + # kwargs["tcp"] = True + try: + query = str(query).strip() + kwargs.pop("rdtype", None) + rdtype = kwargs.pop("type", "A") + if is_ip(query): + return await self._resolve_ip(query, **kwargs) + else: + return await self._resolve_hostname(query, rdtype=rdtype, **kwargs) + except BaseException: + log.trace(f"Caught exception in resolve_raw({query}, {kwargs}):") + log.trace(traceback.format_exc()) + raise + + async def _resolve_hostname(self, query, **kwargs): + """Translate a hostname into its corresponding IP addresses. + + This is the foundational function for converting a domain name into its associated IP addresses. It's designed + for internal use within the class and handles retries, caching, and a variety of error/timeout scenarios. + It also respects certain configurations that might ask to skip certain types of queries. Results are returned + in the default dnspython answer object format. + + Args: + query (str): The hostname to resolve. + rdtype (str, optional): The type of DNS record to query (e.g., 'A', 'AAAA'). Defaults to 'A'. + retries (int, optional): The number of times to retry on failure. Defaults to class-wide `retries`. + use_cache (bool, optional): Whether to check the cache before trying a fresh resolution. Defaults to True. + **kwargs: Additional arguments that might be passed to the resolver. + + Returns: + tuple: A tuple containing: + - list: A list of resolved IP addresses. + - list: A list of errors encountered during the resolution process. + + Examples: + >>> results, errors = await _resolve_hostname("google.com") + (, []) + """ + self.debug(f"Resolving {query} with kwargs={kwargs}") + results = [] + errors = [] + rdtype = kwargs.get("rdtype", "A") + + # skip certain queries if requested + if rdtype in self.dns_omit_queries: + if any(h == query or query.endswith(f".{h}") for h in self.dns_omit_queries[rdtype]): + self.debug(f"Skipping {rdtype}:{query} because it's omitted in the config") + return results, errors + + parent = parent_domain(query) + retries = kwargs.pop("retries", self.retries) + use_cache = kwargs.pop("use_cache", True) + tries_left = int(retries) + 1 + parent_hash = hash(f"{parent}:{rdtype}") + dns_cache_hash = hash(f"{query}:{rdtype}") + while tries_left > 0: + try: + if use_cache: + results = self._dns_cache.get(dns_cache_hash, []) + if not results: + error_count = self._errors.get(parent_hash, 0) + if error_count >= self.abort_threshold: + connectivity = await self._connectivity_check() + if connectivity: + log.verbose( + f'Aborting query "{query}" because failed {rdtype} queries for "{parent}" ({error_count:,}) exceeded abort threshold ({self.abort_threshold:,})' + ) + if parent_hash not in self._dns_warnings: + log.verbose( + f'Aborting future {rdtype} queries to "{parent}" because error count ({error_count:,}) exceeded abort threshold ({self.abort_threshold:,})' + ) + self._dns_warnings.add(parent_hash) + return results, errors + results = await self._catch(self.resolver.resolve, query, **kwargs) + if use_cache: + self._dns_cache[dns_cache_hash] = results + if parent_hash in self._errors: + self._errors[parent_hash] = 0 + break + except ( + dns.resolver.NoNameservers, + dns.exception.Timeout, + dns.resolver.LifetimeTimeout, + TimeoutError, + ) as e: + try: + self._errors[parent_hash] += 1 + except KeyError: + self._errors[parent_hash] = 1 + errors.append(e) + # don't retry if we get a SERVFAIL + if isinstance(e, dns.resolver.NoNameservers): + break + tries_left -= 1 + err_msg = ( + f'DNS error or timeout for {rdtype} query "{query}" ({self._errors[parent_hash]:,} so far): {e}' + ) + if tries_left > 0: + retry_num = (retries + 1) - tries_left + self.debug(err_msg) + self.debug(f"Retry (#{retry_num}) resolving {query} with kwargs={kwargs}") + else: + log.verbose(err_msg) + + if results: + self._last_dns_success = time.time() + self.debug(f"Answers for {query} with kwargs={kwargs}: {list(results)}") + + if errors: + self.debug(f"Errors for {query} with kwargs={kwargs}: {errors}") + + return results, errors + + async def _resolve_ip(self, query, **kwargs): + """Translate an IP address into a corresponding DNS name. + + This is the most basic function that will convert an IP address into its associated domain name. It handles + retries, caching, and multiple types of timeout/error scenarios internally. The function is intended for + internal use and should not be directly called by modules without understanding its intricacies. + + Args: + query (str): The IP address to be reverse-resolved. + retries (int, optional): The number of times to retry on failure. Defaults to 0. + use_cache (bool, optional): Whether to check the cache for the result before attempting resolution. Defaults to True. + **kwargs: Additional arguments to be passed to the resolution process. + + Returns: + tuple: A tuple containing: + - list: A list of resolved domain names (in default dnspython answer format). + - list: A list of errors encountered during resolution. + + Examples: + >>> results, errors = await _resolve_ip("8.8.8.8") + (, []) + """ + self.debug(f"Reverse-resolving {query} with kwargs={kwargs}") + retries = kwargs.pop("retries", 0) + use_cache = kwargs.pop("use_cache", True) + tries_left = int(retries) + 1 + results = [] + errors = [] + dns_cache_hash = hash(f"{query}:PTR") + while tries_left > 0: + try: + if use_cache: + results = self._dns_cache.get(dns_cache_hash, []) + if not results: + results = await self._catch(self.resolver.resolve_address, query, **kwargs) + if use_cache: + self._dns_cache[dns_cache_hash] = results + break + except ( + dns.exception.Timeout, + dns.resolver.LifetimeTimeout, + dns.resolver.NoNameservers, + TimeoutError, + ) as e: + errors.append(e) + # don't retry if we get a SERVFAIL + if isinstance(e, dns.resolver.NoNameservers): + self.debug(f"{e} (query={query}, kwargs={kwargs})") + break + else: + tries_left -= 1 + if tries_left > 0: + retry_num = (retries + 2) - tries_left + self.debug(f"Retrying (#{retry_num}) {query} with kwargs={kwargs}") + + if results: + self._last_dns_success = time.time() + + return results, errors + + async def resolve_batch(self, queries, threads=10, **kwargs): + """ + A helper to execute a bunch of DNS requests. + + Args: + queries (list): List of queries to resolve. + **kwargs: Additional keyword arguments to pass to `resolve()`. + + Yields: + tuple: A tuple containing the original query and its resolved value. + + Examples: + >>> import asyncio + >>> async def example_usage(): + ... async for result in resolve_batch(['www.evilcorp.com', 'evilcorp.com']): + ... print(result) + ('www.evilcorp.com', {'1.1.1.1'}) + ('evilcorp.com', {'2.2.2.2'}) + """ + tasks = {} + + def new_task(query): + task = asyncio.create_task(self.resolve(query, **kwargs)) + tasks[task] = query + + queries = list(queries) + for _ in range(threads): # Start initial batch of tasks + if queries: # Ensure there are args to process + new_task(queries.pop(0)) + + while tasks: # While there are tasks pending + # Wait for the first task to complete + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + for task in done: + results = task.result() + query = tasks.pop(task) + + if results: + yield (query, results) + + if queries: # Start a new task for each one completed, if URLs remain + new_task(queries.pop(0)) + + async def resolve_raw_batch(self, queries, threads=10): + tasks = {} + + def new_task(query, rdtype): + task = asyncio.create_task(self.resolve_raw(query, type=rdtype)) + tasks[task] = (query, rdtype) + + queries = list(queries) + for _ in range(threads): # Start initial batch of tasks + if queries: # Ensure there are args to process + new_task(*queries.pop(0)) + + while tasks: # While there are tasks pending + # Wait for the first task to complete + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + for task in done: + answers, errors = task.result() + query, rdtype = tasks.pop(task) + for answer in answers: + yield ((query, rdtype), (answer, errors)) + + if queries: # Start a new task for each one completed, if URLs remain + new_task(*queries.pop(0)) + + async def _catch(self, callback, *args, **kwargs): + """ + Asynchronously catches exceptions thrown during DNS resolution and logs them. + + This method wraps around a given asynchronous callback function to handle different + types of DNS exceptions and general exceptions. It logs the exceptions for debugging + and, in some cases, re-raises them. + + Args: + callback (callable): The asynchronous function to be executed. + *args: Positional arguments to pass to the callback. + **kwargs: Keyword arguments to pass to the callback. + + Returns: + Any: The return value of the callback function, or an empty list if an exception is caught. + + Raises: + dns.resolver.NoNameservers: When no nameservers could be reached. + """ + try: + return await callback(*args, **kwargs) + except dns.resolver.NoNameservers: + raise + except (dns.exception.Timeout, dns.resolver.LifetimeTimeout, TimeoutError): + log.debug(f"DNS query with args={args}, kwargs={kwargs} timed out after {self.timeout} seconds") + raise + except dns.exception.DNSException as e: + self.debug(f"{e} (args={args}, kwargs={kwargs})") + except Exception as e: + log.warning(f"Error in {callback.__qualname__}() with args={args}, kwargs={kwargs}: {e}") + log.trace(traceback.format_exc()) + return [] + + async def is_wildcard(self, query, ips=None, rdtype=None): + """ + Use this method to check whether a *host* is a wildcard entry + + This can reliably tell the difference between a valid DNS record and a wildcard within a wildcard domain. + + If you want to know whether a domain is using wildcard DNS, use `is_wildcard_domain()` instead. + + Args: + query (str): The hostname to check for a wildcard entry. + ips (list, optional): List of IPs to compare against, typically obtained from a previous DNS resolution of the query. + rdtype (str, optional): The DNS record type (e.g., "A", "AAAA") to consider during the check. + + Returns: + dict: A dictionary indicating if the query is a wildcard for each checked DNS record type. + Keys are DNS record types like "A", "AAAA", etc. + Values are tuples where the first element is a boolean indicating if the query is a wildcard, + and the second element is the wildcard parent if it's a wildcard. + + Raises: + ValueError: If only one of `ips` or `rdtype` is specified or if no valid IPs are specified. + + Examples: + >>> is_wildcard("www.github.io") + {"A": (True, "github.io"), "AAAA": (True, "github.io")} + + >>> is_wildcard("www.evilcorp.com", ips=["93.184.216.34"], rdtype="A") + {"A": (False, "evilcorp.com")} + + Note: + `is_wildcard` can be True, False, or None (indicating that wildcard detection was inconclusive) + """ + result = {} + + parent = parent_domain(query) + parents = list(domain_parents(query)) + + rdtypes_to_check = [rdtype] if rdtype is not None else all_rdtypes + + query_baseline = dict() + # if the caller hasn't already done the work of resolving the IPs + if ips is None: + # then resolve the query for all rdtypes + queries = [(query, t) for t in rdtypes_to_check] + async for (query, _rdtype), (answers, errors) in self.resolve_raw_batch(queries): + answers = extract_targets(answers) + if answers: + query_baseline[_rdtype] = set([a[1] for a in answers]) + else: + if errors: + self.debug(f"Failed to resolve {query} ({_rdtype}) during wildcard detection") + result[_rdtype] = (None, parent) + continue + else: + # otherwise, we can skip all that + cleaned_ips = set([clean_dns_record(ip) for ip in ips]) + if not cleaned_ips: + raise ValueError("Valid IPs must be specified") + query_baseline[rdtype] = cleaned_ips + if not query_baseline: + return result + + # once we've resolved the base query and have IP addresses to work with + # we can compare the IPs to the ones we have on file for wildcards + + # for every parent domain, starting with the shortest + try: + for host in parents[::-1]: + # make sure we've checked that domain for wildcards + await self.is_wildcard_domain(host) + + # for every rdtype + for _rdtype in list(query_baseline): + # get the IPs from above + query_ips = query_baseline.get(_rdtype, set()) + host_hash = hash(host) + + if host_hash in self._wildcard_cache: + # then get its IPs from our wildcard cache + wildcard_rdtypes = self._wildcard_cache[host_hash] + + # then check to see if our IPs match the wildcard ones + if _rdtype in wildcard_rdtypes: + wildcard_ips = wildcard_rdtypes[_rdtype] + # if our IPs match the wildcard ones, then ladies and gentlemen we have a wildcard + is_wildcard = any(r in wildcard_ips for r in query_ips) + + if is_wildcard and not result.get(_rdtype, (None, None))[0] is True: + result[_rdtype] = (True, host) + + # if we've reached a point where the dns name is a complete wildcard, class can be dismissed early + base_query_rdtypes = set(query_baseline) + wildcard_rdtypes_set = set([k for k, v in result.items() if v[0] is True]) + if base_query_rdtypes and wildcard_rdtypes_set and base_query_rdtypes == wildcard_rdtypes_set: + log.debug( + f"Breaking from wildcard detection for {query} at {host} because base query rdtypes ({base_query_rdtypes}) == wildcard rdtypes ({wildcard_rdtypes_set})" + ) + raise DNSWildcardBreak() + + except DNSWildcardBreak: + pass + + return result + + async def is_wildcard_domain(self, domain, log_info=False): + """ + Check whether a given host or its children make use of wildcard DNS entries. Wildcard DNS can have + various implications, particularly in subdomain enumeration and subdomain takeovers. + + Args: + domain (str): The domain to check for wildcard DNS entries. + log_info (bool, optional): Whether to log the result of the check. Defaults to False. + + Returns: + dict: A dictionary where the keys are the parent domains that have wildcard DNS entries, + and the values are another dictionary of DNS record types ("A", "AAAA", etc.) mapped to + sets of their resolved IP addresses. + + Examples: + >>> is_wildcard_domain("github.io") + {"github.io": {"A": {"1.2.3.4"}, "AAAA": {"dead::beef"}}} + + >>> is_wildcard_domain("example.com") + {} + """ + wildcard_domain_results = {} + + rdtypes_to_check = set(all_rdtypes) + + # make a list of its parents + parents = list(domain_parents(domain, include_self=True)) + # and check each of them, beginning with the highest parent (i.e. the root domain) + for i, host in enumerate(parents[::-1]): + # have we checked this host before? + host_hash = hash(host) + async with self._wildcard_lock.lock(host_hash): + # if we've seen this host before + if host_hash in self._wildcard_cache: + wildcard_domain_results[host] = self._wildcard_cache[host_hash] + continue + + log.verbose(f"Checking if {host} is a wildcard") + + # determine if this is a wildcard domain + + # resolve a bunch of random subdomains of the same parent + is_wildcard = False + wildcard_results = dict() + + queries = [] + for rdtype in rdtypes_to_check: + for _ in range(self.wildcard_tests): + rand_query = f"{rand_string(digits=False, length=10)}.{host}" + queries.append((rand_query, rdtype)) + + async for (query, rdtype), (answers, errors) in self.resolve_raw_batch(queries): + answers = extract_targets(answers) + if answers: + is_wildcard = True + if not rdtype in wildcard_results: + wildcard_results[rdtype] = set() + wildcard_results[rdtype].update(set(a[1] for a in answers)) + # we know this rdtype is a wildcard + # so we don't need to check it anymore + with suppress(KeyError): + rdtypes_to_check.remove(rdtype) + + self._wildcard_cache.update({host_hash: wildcard_results}) + wildcard_domain_results.update({host: wildcard_results}) + if is_wildcard: + wildcard_rdtypes_str = ",".join(sorted([t.upper() for t, r in wildcard_results.items() if r])) + log_fn = log.verbose + if log_info: + log_fn = log.info + log_fn(f"Encountered domain with wildcard DNS ({wildcard_rdtypes_str}): {host}") + else: + log.verbose(f"Finished checking {host}, it is not a wildcard") + + return wildcard_domain_results + + @property + def dns_connectivity_lock(self): + if self._dns_connectivity_lock is None: + self._dns_connectivity_lock = asyncio.Lock() + return self._dns_connectivity_lock + + async def _connectivity_check(self, interval=5): + """ + Periodically checks for an active internet connection by attempting DNS resolution. + + Args: + interval (int, optional): The time interval, in seconds, at which to perform the check. + Defaults to 5 seconds. + + Returns: + bool: True if there is an active internet connection, False otherwise. + + Examples: + >>> await _connectivity_check() + True + """ + if self._last_dns_success is not None: + if time.time() - self._last_dns_success < interval: + return True + dns_server_working = [] + async with self.dns_connectivity_lock: + with suppress(Exception): + dns_server_working = await self._catch(self.resolver.resolve, "www.google.com", rdtype="A") + if dns_server_working: + self._last_dns_success = time.time() + return True + if time.time() - self._last_connectivity_warning > interval: + log.warning(f"DNS queries are failing, please check your internet connection") + self._last_connectivity_warning = time.time() + self._errors.clear() + return False + + def debug(self, *args, **kwargs): + if self._debug: + log.trace(*args, **kwargs) + + @property + def in_tests(self): + return os.getenv("BBOT_TESTING", "") == "True" + + async def _mock_dns(self, mock_data): + from .mock import MockResolver + + self.resolver = MockResolver(mock_data) diff --git a/bbot/core/helpers/dns/helpers.py b/bbot/core/helpers/dns/helpers.py new file mode 100644 index 000000000..061ed829c --- /dev/null +++ b/bbot/core/helpers/dns/helpers.py @@ -0,0 +1,61 @@ +import logging + +from bbot.core.helpers.regexes import dns_name_regex +from bbot.core.helpers.misc import clean_dns_record, smart_decode + +log = logging.getLogger("bbot.core.helpers.dns") + + +def extract_targets(record): + """ + Extracts hostnames or IP addresses from a given DNS record. + + This method reads the DNS record's type and based on that, extracts the target + hostnames or IP addresses it points to. The type of DNS record + (e.g., "A", "MX", "CNAME", etc.) determines which fields are used for extraction. + + Args: + record (dns.rdata.Rdata): The DNS record to extract information from. + + Returns: + set: A set of tuples, each containing the DNS record type and the extracted value. + + Examples: + >>> from dns.rrset import from_text + >>> record = from_text('www.example.com', 3600, 'IN', 'A', '192.0.2.1') + >>> extract_targets(record[0]) + {('A', '192.0.2.1')} + + >>> record = from_text('example.com', 3600, 'IN', 'MX', '10 mail.example.com.') + >>> extract_targets(record[0]) + {('MX', 'mail.example.com')} + + """ + results = set() + + def add_result(rdtype, _record): + cleaned = clean_dns_record(_record) + if cleaned: + results.add((rdtype, cleaned)) + + rdtype = str(record.rdtype.name).upper() + if rdtype in ("A", "AAAA", "NS", "CNAME", "PTR"): + add_result(rdtype, record) + elif rdtype == "SOA": + add_result(rdtype, record.mname) + elif rdtype == "MX": + add_result(rdtype, record.exchange) + elif rdtype == "SRV": + add_result(rdtype, record.target) + elif rdtype == "TXT": + for s in record.strings: + s = smart_decode(s) + for match in dns_name_regex.finditer(s): + start, end = match.span() + host = s[start:end] + add_result(rdtype, host) + elif rdtype == "NSEC": + add_result(rdtype, record.next) + else: + log.warning(f'Unknown DNS record type "{rdtype}"') + return results diff --git a/bbot/core/helpers/dns/mock.py b/bbot/core/helpers/dns/mock.py new file mode 100644 index 000000000..70d978aff --- /dev/null +++ b/bbot/core/helpers/dns/mock.py @@ -0,0 +1,56 @@ +import dns + + +class MockResolver: + + def __init__(self, mock_data=None): + self.mock_data = mock_data if mock_data else {} + self.nameservers = ["127.0.0.1"] + + async def resolve_address(self, ipaddr, *args, **kwargs): + modified_kwargs = {} + modified_kwargs.update(kwargs) + modified_kwargs["rdtype"] = "PTR" + return await self.resolve(str(dns.reversename.from_address(ipaddr)), *args, **modified_kwargs) + + def create_dns_response(self, query_name, rdtype): + query_name = query_name.strip(".") + answers = self.mock_data.get(query_name, {}).get(rdtype, []) + if not answers: + raise dns.resolver.NXDOMAIN(f"No answer found for {query_name} {rdtype}") + + message_text = f"""id 1234 +opcode QUERY +rcode NOERROR +flags QR AA RD +;QUESTION +{query_name}. IN {rdtype} +;ANSWER""" + for answer in answers: + message_text += f"\n{query_name}. 1 IN {rdtype} {answer}" + + message_text += "\n;AUTHORITY\n;ADDITIONAL\n" + message = dns.message.from_text(message_text) + return message + + async def resolve(self, query_name, rdtype=None): + if rdtype is None: + rdtype = "A" + elif isinstance(rdtype, str): + rdtype = rdtype.upper() + else: + rdtype = str(rdtype.name).upper() + + domain_name = dns.name.from_text(query_name) + rdtype_obj = dns.rdatatype.from_text(rdtype) + + if "_NXDOMAIN" in self.mock_data and query_name in self.mock_data["_NXDOMAIN"]: + # Simulate the NXDOMAIN exception + raise dns.resolver.NXDOMAIN + + try: + response = self.create_dns_response(query_name, rdtype) + answer = dns.resolver.Answer(domain_name, rdtype_obj, dns.rdataclass.IN, response) + return answer + except dns.resolver.NXDOMAIN: + return [] diff --git a/bbot/core/helpers/files.py b/bbot/core/helpers/files.py index 438f74112..fb92d1c8b 100644 --- a/bbot/core/helpers/files.py +++ b/bbot/core/helpers/files.py @@ -1,6 +1,5 @@ import os import logging -import threading import traceback from contextlib import suppress @@ -104,7 +103,13 @@ def feed_pipe(self, pipe, content, text=True): text (bool, optional): If True, the content is decoded using smart_decode function. If False, smart_encode function is used. Defaults to True. """ - t = threading.Thread(target=self._feed_pipe, args=(pipe, content), kwargs={"text": text}, daemon=True) + t = self.preset.core.create_thread( + target=self._feed_pipe, + args=(pipe, content), + kwargs={"text": text}, + daemon=True, + custom_name="bbot feed_pipe()", + ) t.start() @@ -127,7 +132,9 @@ def tempfile_tail(self, callback): rm_at_exit(filename) try: os.mkfifo(filename) - t = threading.Thread(target=tail, args=(filename, callback), daemon=True) + t = self.preset.core.create_thread( + target=tail, args=(filename, callback), daemon=True, custom_name="bbot tempfile_tail()" + ) t.start() except Exception as e: log.error(f"Error setting up tail for file {filename}: {e}") diff --git a/bbot/core/helpers/helper.py b/bbot/core/helpers/helper.py index 899f3ab0b..77aa22566 100644 --- a/bbot/core/helpers/helper.py +++ b/bbot/core/helpers/helper.py @@ -1,19 +1,21 @@ import os +import asyncio import logging from pathlib import Path +import multiprocessing as mp +from functools import partial +from concurrent.futures import ProcessPoolExecutor from . import misc from .dns import DNSHelper from .web import WebHelper from .diff import HttpCompare -from .cloud import CloudHelper +from .regex import RegexHelper from .wordcloud import WordCloud from .interactsh import Interactsh from ...scanner.target import Target -from ...modules.base import BaseModule from .depsinstaller import DepsInstaller - log = logging.getLogger("bbot.core.helpers") @@ -51,10 +53,9 @@ class ConfigAwareHelper: from .cache import cache_get, cache_put, cache_filename, is_cached from .command import run, run_live, _spawn_proc, _prepare_command_kwargs - def __init__(self, config, scan=None): - self.config = config - self._scan = scan - self.bbot_home = Path(self.config.get("home", "~/.bbot")).expanduser().resolve() + def __init__(self, preset): + self.preset = preset + self.bbot_home = self.preset.bbot_home self.cache_dir = self.bbot_home / "cache" self.temp_dir = self.bbot_home / "temp" self.tools_dir = self.bbot_home / "tools" @@ -68,20 +69,72 @@ def __init__(self, config, scan=None): self.mkdir(self.tools_dir) self.mkdir(self.lib_dir) + self._loop = None + + # multiprocessing thread pool + start_method = mp.get_start_method() + if start_method != "spawn": + self.warning(f"Multiprocessing spawn method is set to {start_method}.") + + # we spawn 1 fewer processes than cores + # this helps to avoid locking up the system or competing with the main python process for cpu time + num_processes = max(1, mp.cpu_count() - 1) + self.process_pool = ProcessPoolExecutor(max_workers=num_processes) + + self._cloud = None + + self.re = RegexHelper(self) self.dns = DNSHelper(self) - self.web = WebHelper(self) + self._web = None + self.config_aware_validators = self.validators.Validators(self) self.depsinstaller = DepsInstaller(self) self.word_cloud = WordCloud(self) self.dummy_modules = {} - # cloud helpers - self.cloud = CloudHelper(self) + @property + def web(self): + if self._web is None: + self._web = WebHelper(self) + return self._web + + @property + def cloud(self): + if self._cloud is None: + from cloudcheck import cloud_providers + + self._cloud = cloud_providers + return self._cloud + + def bloom_filter(self, size): + from .bloom import BloomFilter + + return BloomFilter(size) def interactsh(self, *args, **kwargs): return Interactsh(self, *args, **kwargs) - def http_compare(self, url, allow_redirects=False, include_cache_buster=True): - return HttpCompare(url, self, allow_redirects=allow_redirects, include_cache_buster=include_cache_buster) + def http_compare( + self, + url, + allow_redirects=False, + include_cache_buster=True, + headers=None, + cookies=None, + method="GET", + data=None, + timeout=15, + ): + return HttpCompare( + url, + self, + allow_redirects=allow_redirects, + include_cache_buster=include_cache_buster, + headers=headers, + cookies=cookies, + timeout=timeout, + method=method, + data=data, + ) def temp_filename(self, extension=None): """ @@ -96,31 +149,56 @@ def clean_old_scans(self): _filter = lambda x: x.is_dir() and self.regexes.scan_name_regex.match(x.name) self.clean_old(self.scans_dir, keep=self.keep_old_scans, filter=_filter) - def make_target(self, *events): - return Target(self.scan, *events) + def make_target(self, *events, **kwargs): + return Target(*events, **kwargs) @property - def scan(self): - if self._scan is None: - from bbot.scanner import Scanner + def config(self): + return self.preset.config - self._scan = Scanner() - return self._scan + @property + def web_config(self): + return self.preset.web_config @property - def in_tests(self): - return os.environ.get("BBOT_TESTING", "") == "True" + def scan(self): + return self.preset.scan - def _make_dummy_module(self, name, _type="scan"): + @property + def loop(self): """ - Construct a dummy module, for attachment to events + Get the current event loop """ - try: - return self.dummy_modules[name] - except KeyError: - dummy = DummyModule(scan=self.scan, name=name, _type=_type) - self.dummy_modules[name] = dummy - return dummy + if self._loop is None: + self._loop = asyncio.get_running_loop() + return self._loop + + def run_in_executor(self, callback, *args, **kwargs): + """ + Run a synchronous task in the event loop's default thread pool executor + + Examples: + Execute callback: + >>> result = await self.helpers.run_in_executor(callback_fn, arg1, arg2) + """ + callback = partial(callback, **kwargs) + return self.loop.run_in_executor(None, callback, *args) + + def run_in_executor_mp(self, callback, *args, **kwargs): + """ + Same as run_in_executor() except with a process pool executor + Use only in cases where callback is CPU-bound + + Examples: + Execute callback: + >>> result = await self.helpers.run_in_executor_mp(callback_fn, arg1, arg2) + """ + callback = partial(callback, **kwargs) + return self.loop.run_in_executor(self.process_pool, callback, *args) + + @property + def in_tests(self): + return os.environ.get("BBOT_TESTING", "") == "True" def __getattribute__(self, attr): """ @@ -163,12 +241,3 @@ def __getattribute__(self, attr): except AttributeError: # then die raise AttributeError(f'Helper has no attribute "{attr}"') - - -class DummyModule(BaseModule): - _priority = 4 - - def __init__(self, *args, **kwargs): - self._name = kwargs.pop("name") - self._type = kwargs.pop("_type") - super().__init__(*args, **kwargs) diff --git a/bbot/core/helpers/interactsh.py b/bbot/core/helpers/interactsh.py index aad4a169f..f707fac93 100644 --- a/bbot/core/helpers/interactsh.py +++ b/bbot/core/helpers/interactsh.py @@ -11,7 +11,7 @@ from Crypto.PublicKey import RSA from Crypto.Cipher import AES, PKCS1_OAEP -from bbot.core.errors import InteractshError +from bbot.errors import InteractshError log = logging.getLogger("bbot.core.helpers.interactsh") diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index 57e7e189e..cce1c1ff8 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -1,42 +1,22 @@ import os -import re import sys import copy -import idna import json -import atexit -import codecs -import psutil import random -import shutil -import signal import string import asyncio -import difflib -import inspect import logging -import platform import ipaddress -import traceback +import regex as re import subprocess as sp from pathlib import Path -from itertools import islice -from datetime import datetime -from tabulate import tabulate -import wordninja as _wordninja from contextlib import suppress from unidecode import unidecode # noqa F401 -import cloudcheck as _cloudcheck -import tldextract as _tldextract -import xml.etree.ElementTree as ET -from collections.abc import Mapping -from hashlib import sha1 as hashlib_sha1 from asyncio import create_task, gather, sleep, wait_for # noqa from urllib.parse import urlparse, quote, unquote, urlunparse, urljoin # noqa F401 from .url import * # noqa F401 -from .. import errors -from .logger import log_to_stderr +from ... import errors from . import regexes as bbot_regexes from .names_generator import random_name, names, adjectives # noqa F401 @@ -257,6 +237,7 @@ def split_host_port(d): port = match.group(3) if port is None and scheme is not None: + scheme = scheme.lower() if scheme in ("https", "wss"): port = 443 elif scheme in ("http", "ws"): @@ -479,6 +460,8 @@ def tldextract(data): - Utilizes `smart_decode` to preprocess the data. - Makes use of the `tldextract` library for extraction. """ + import tldextract as _tldextract + return _tldextract.extract(smart_decode(data)) @@ -656,7 +639,7 @@ def is_ip_type(i): >>> is_ip_type("192.168.1.0/24") False """ - return isinstance(i, ipaddress._BaseV4) or isinstance(i, ipaddress._BaseV6) + return ipaddress._IPAddressBase in i.__class__.__mro__ def make_ip_type(s): @@ -682,78 +665,17 @@ def make_ip_type(s): >>> make_ip_type("evilcorp.com") 'evilcorp.com' """ + if not s: + raise ValueError(f'Invalid hostname: "{s}"') # IP address with suppress(Exception): - return ipaddress.ip_address(str(s).strip()) + return ipaddress.ip_address(s) # IP network with suppress(Exception): - return ipaddress.ip_network(str(s).strip(), strict=False) + return ipaddress.ip_network(s, strict=False) return s -def host_in_host(host1, host2): - """ - Checks if host1 is included within host2, either as a subdomain, IP, or IP network. - Used for scope calculations/decisions within BBOT. - - Args: - host1 (str or ipaddress.IPv4Address or ipaddress.IPv6Address or ipaddress.IPv4Network or ipaddress.IPv6Network): - The host to check for inclusion within host2. - host2 (str or ipaddress.IPv4Address or ipaddress.IPv6Address or ipaddress.IPv4Network or ipaddress.IPv6Network): - The host within which to check for the inclusion of host1. - - Returns: - bool: True if host1 is included in host2, otherwise False. - - Examples: - >>> host_in_host("www.evilcorp.com", "evilcorp.com") - True - >>> host_in_host("evilcorp.com", "www.evilcorp.com") - False - >>> host_in_host(ipaddress.IPv6Address('dead::beef'), ipaddress.IPv6Network('dead::/64')) - True - >>> host_in_host(ipaddress.IPv4Address('192.168.1.1'), ipaddress.IPv4Network('10.0.0.0/8')) - False - - Notes: - - If checking an IP address/network, you MUST FIRST convert your IP into an ipaddress object (e.g. via `make_ip_type()`) before passing it to this function. - """ - - """ - Is host1 included in host2? - "www.evilcorp.com" in "evilcorp.com"? --> True - "evilcorp.com" in "www.evilcorp.com"? --> False - IPv6Address('dead::beef') in IPv6Network('dead::/64')? --> True - IPv4Address('192.168.1.1') in IPv4Network('10.0.0.0/8')? --> False - - Very important! Used throughout BBOT for scope calculations/decisions. - - Works with hostnames, IPs, and IP networks. - """ - - if not host1 or not host2: - return False - - # check if hosts are IP types - host1_ip_type = is_ip_type(host1) - host2_ip_type = is_ip_type(host2) - # if both hosts are IP types - if host1_ip_type and host2_ip_type: - if not host1.version == host2.version: - return False - host1_net = ipaddress.ip_network(host1) - host2_net = ipaddress.ip_network(host2) - return host1_net.subnet_of(host2_net) - - # else hostnames - elif not (host1_ip_type or host2_ip_type): - host2_len = len(host2.split(".")) - host1_truncated = ".".join(host1.split(".")[-host2_len:]) - return host1_truncated == host2 - - return False - - def sha1(data): """ Computes the SHA-1 hash of the given data. @@ -768,6 +690,8 @@ def sha1(data): >>> sha1("asdf").hexdigest() '3da541559918a808c2402bba5012f6c60b27661c' """ + from hashlib import sha1 as hashlib_sha1 + if isinstance(data, dict): data = json.dumps(data, sort_keys=True) return hashlib_sha1(smart_encode(data)) @@ -841,6 +765,8 @@ def recursive_decode(data, max_depth=5): >>> recursive_dcode("%5Cu0020%5Cu041f%5Cu0440%5Cu0438%5Cu0432%5Cu0435%5Cu0442%5Cu0021") " Привет!" """ + import codecs + # Decode newline and tab escapes data = backslash_regex.sub( lambda match: {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "v": "\v"}.get(match.group("char")), data @@ -897,133 +823,105 @@ def truncate_string(s, n): return s -def extract_params_json(json_data): +def extract_params_json(json_data, compare_mode="getparam"): """ - Extracts keys from a JSON object and returns them as a set. Used by the `paramminer_headers` module. + Extracts key-value pairs from a JSON object and returns them as a set of tuples. Used by the `paramminer_headers` module. Args: json_data (str): JSON-formatted string containing key-value pairs. Returns: - set: A set containing the keys present in the JSON object. + set: A set of tuples containing the keys and their corresponding values present in the JSON object. Raises: - Logs a message if JSONDecodeError occurs. + Returns an empty set if JSONDecodeError occurs. Examples: >>> extract_params_json('{"a": 1, "b": {"c": 2}}') - {'a', 'b', 'c'} + {('a', 1), ('b', {'c': 2}), ('c', 2)} """ try: data = json.loads(json_data) except json.JSONDecodeError: - log.debug("Invalid JSON supplied. Returning empty list.") return set() - keys = set() - stack = [data] + key_value_pairs = set() + stack = [(data, "")] while stack: - current_data = stack.pop() + current_data, path = stack.pop() if isinstance(current_data, dict): for key, value in current_data.items(): - keys.add(key) - if isinstance(value, (dict, list)): - stack.append(value) + full_key = f"{path}.{key}" if path else key + if isinstance(value, dict): + stack.append((value, full_key)) + elif isinstance(value, list): + stack.append((value, full_key)) + else: + if validate_parameter(full_key, compare_mode): + key_value_pairs.add((full_key, value)) elif isinstance(current_data, list): for item in current_data: if isinstance(item, (dict, list)): - stack.append(item) - - return keys + stack.append((item, path)) + return key_value_pairs -def extract_params_xml(xml_data): +def extract_params_xml(xml_data, compare_mode="getparam"): """ - Extracts tags from an XML object and returns them as a set. + Extracts tags and their text values from an XML object and returns them as a set of tuples. Args: xml_data (str): XML-formatted string containing elements. Returns: - set: A set containing the tags present in the XML object. + set: A set of tuples containing the tags and their corresponding text values present in the XML object. Raises: - Logs a message if ParseError occurs. + Returns an empty set if ParseError occurs. Examples: - >>> extract_params_xml('') - {'child1', 'child2', 'root'} + >>> extract_params_xml('value') + {('root', None), ('child1', None), ('child2', 'value')} """ + import xml.etree.ElementTree as ET + try: root = ET.fromstring(xml_data) except ET.ParseError: - log.debug("Invalid XML supplied. Returning empty list.") return set() - tags = set() + tag_value_pairs = set() stack = [root] while stack: current_element = stack.pop() - tags.add(current_element.tag) + if validate_parameter(current_element.tag, compare_mode): + tag_value_pairs.add((current_element.tag, current_element.text)) for child in current_element: stack.append(child) - return tags + return tag_value_pairs -def extract_params_html(html_data): - """ - Extracts parameters from an HTML object, yielding them one at a time. +# Define valid characters for each mode based on RFCs +valid_chars_dict = { + "header": set( + chr(c) for c in range(33, 127) if chr(c) in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + ), + "getparam": set(chr(c) for c in range(33, 127) if chr(c) not in ":/?#[]@!$&'()*+,;="), + "postparam": set(chr(c) for c in range(33, 127) if chr(c) not in ":/?#[]@!$&'()*+,;="), + "cookie": set(chr(c) for c in range(33, 127) if chr(c) not in '()<>@,;:"/[]?={} \t'), +} - Args: - html_data (str): HTML-formatted string. - Yields: - str: A string containing the parameter found in HTML object. - - Examples: - >>> html_data = ''' - ... - ... - ... - ... Click Me - ... - ... - ... - ... ''' - >>> list(extract_params_html(html_data)) - ['user', 'param2', 'param3'] - """ - input_tag = bbot_regexes.input_tag_regex.findall(html_data) - - for i in input_tag: - log.debug(f"FOUND PARAM ({i}) IN INPUT TAGS") - yield i - - # check for jquery get parameters - jquery_get = bbot_regexes.jquery_get_regex.findall(html_data) - - for i in jquery_get: - log.debug(f"FOUND PARAM ({i}) IN JQUERY GET PARAMS") - yield i - - # check for jquery post parameters - jquery_post = bbot_regexes.jquery_post_regex.findall(html_data) - if jquery_post: - for i in jquery_post: - for x in i.split(","): - s = x.split(":")[0].rstrip() - log.debug(f"FOUND PARAM ({s}) IN A JQUERY POST PARAMS") - yield s - - a_tag = bbot_regexes.a_tag_regex.findall(html_data) - for s in a_tag: - log.debug(f"FOUND PARAM ({s}) IN A TAG GET PARAMS") - yield s +def validate_parameter(param, compare_mode): + compare_mode = compare_mode.lower() + if len(param) > 100: + return False + if compare_mode not in valid_chars_dict: + raise ValueError(f"Invalid compare_mode: {compare_mode}") + allowed_chars = valid_chars_dict[compare_mode] + return set(param).issubset(allowed_chars) def extract_words(data, acronyms=True, wordninja=True, model=None, max_length=100, word_regexes=None): @@ -1047,6 +945,7 @@ def extract_words(data, acronyms=True, wordninja=True, model=None, max_length=10 >>> extract_words('blacklanternsecurity') {'black', 'lantern', 'security', 'bls', 'blacklanternsecurity'} """ + import wordninja as _wordninja if word_regexes is None: word_regexes = bbot_regexes.word_regexes @@ -1103,6 +1002,8 @@ def closest_match(s, choices, n=1, cutoff=0.0): >>> closest_match("asdf", ["asd", "fds", "asdff"], n=3) ['asdff', 'asd', 'fds'] """ + import difflib + matches = difflib.get_close_matches(s, choices, n=n, cutoff=cutoff) if not choices or not matches: return @@ -1111,8 +1012,8 @@ def closest_match(s, choices, n=1, cutoff=0.0): return matches -def match_and_exit(s, choices, msg=None, loglevel="HUGEWARNING", exitcode=2): - """Finds the closest match from a list of choices for a given string, logs a warning, and exits the program. +def get_closest_match(s, choices, msg=None): + """Finds the closest match from a list of choices for a given string. This function is particularly useful for CLI applications where you want to validate flags or modules. @@ -1124,23 +1025,27 @@ def match_and_exit(s, choices, msg=None, loglevel="HUGEWARNING", exitcode=2): exitcode (int, optional): The exit code to use when exiting the program. Defaults to 2. Examples: - >>> match_and_exit("some_module", ["some_mod", "some_other_mod"], msg="module") + >>> get_closest_match("some_module", ["some_mod", "some_other_mod"], msg="module") # Output: Could not find module "some_module". Did you mean "some_mod"? - # Exits with code 2 """ if msg is None: msg = "" else: msg += " " closest = closest_match(s, choices) - log_to_stderr(f'Could not find {msg}"{s}". Did you mean "{closest}"?', level="HUGEWARNING") - sys.exit(2) + return f'Could not find {msg}"{s}". Did you mean "{closest}"?' -def kill_children(parent_pid=None, sig=signal.SIGTERM): +def kill_children(parent_pid=None, sig=None): """ Forgive me father for I have sinned """ + import psutil + import signal + + if sig is None: + sig = signal.SIGTERM + try: parent = psutil.Process(parent_pid) except psutil.NoSuchProcess: @@ -1283,6 +1188,8 @@ def rm_at_exit(path): Examples: >>> rm_at_exit("/tmp/test/file1.txt") """ + import atexit + atexit.register(delete_file, path) @@ -1396,6 +1303,8 @@ def which(*executables): >>> which("python", "python3") "/usr/bin/python" """ + import shutil + for e in executables: location = shutil.which(e) if location: @@ -1494,74 +1403,6 @@ def search_dict_values(d, *regexes): yield from search_dict_values(v, *regexes) -def filter_dict(d, *key_names, fuzzy=False, exclude_keys=None, _prev_key=None): - """ - Recursively filter a dictionary based on key names. - - Args: - d (dict): The input dictionary. - *key_names: Names of keys to filter for. - fuzzy (bool): Whether to perform fuzzy matching on keys. - exclude_keys (list, None): List of keys to be excluded from the final dict. - _prev_key (str, None): For internal recursive use; the previous key in the hierarchy. - - Returns: - dict: A dictionary containing only the keys specified in key_names. - - Examples: - >>> filter_dict({"key1": "test", "key2": "asdf"}, "key2") - {"key2": "asdf"} - >>> filter_dict({"key1": "test", "key2": {"key3": "asdf"}}, "key1", "key3", exclude_keys="key2") - {'key1': 'test'} - """ - if exclude_keys is None: - exclude_keys = [] - if isinstance(exclude_keys, str): - exclude_keys = [exclude_keys] - ret = {} - if isinstance(d, dict): - for key in d: - if key in key_names or (fuzzy and any(k in key for k in key_names)): - if not any(k in exclude_keys for k in [key, _prev_key]): - ret[key] = copy.deepcopy(d[key]) - elif isinstance(d[key], list) or isinstance(d[key], dict): - child = filter_dict(d[key], *key_names, fuzzy=fuzzy, _prev_key=key, exclude_keys=exclude_keys) - if child: - ret[key] = child - return ret - - -def clean_dict(d, *key_names, fuzzy=False, exclude_keys=None, _prev_key=None): - """ - Recursively clean unwanted keys from a dictionary. - Useful for removing secrets from a config. - - Args: - d (dict): The input dictionary. - *key_names: Names of keys to remove. - fuzzy (bool): Whether to perform fuzzy matching on keys. - exclude_keys (list, None): List of keys to be excluded from removal. - _prev_key (str, None): For internal recursive use; the previous key in the hierarchy. - - Returns: - dict: A dictionary cleaned of the keys specified in key_names. - - """ - if exclude_keys is None: - exclude_keys = [] - if isinstance(exclude_keys, str): - exclude_keys = [exclude_keys] - d = copy.deepcopy(d) - if isinstance(d, dict): - for key, val in list(d.items()): - if key in key_names or (fuzzy and any(k in key for k in key_names)): - if _prev_key not in exclude_keys: - d.pop(key) - else: - d[key] = clean_dict(val, *key_names, fuzzy=fuzzy, _prev_key=key, exclude_keys=exclude_keys) - return d - - def grouper(iterable, n): """ Grouper groups an iterable into chunks of a given size. @@ -1577,6 +1418,7 @@ def grouper(iterable, n): >>> list(grouper('ABCDEFG', 3)) [['A', 'B', 'C'], ['D', 'E', 'F'], ['G']] """ + from itertools import islice iterable = iter(iterable) return iter(lambda: list(islice(iterable, n)), []) @@ -1655,6 +1497,8 @@ def make_date(d=None, microseconds=False): >>> make_date(microseconds=True) "20220707_1330_35167617" """ + from datetime import datetime + f = "%Y%m%d_%H%M_%S" if microseconds: f += "%f" @@ -1788,6 +1632,8 @@ def rm_rf(f): Examples: >>> rm_rf("/tmp/httpx98323849") """ + import shutil + shutil.rmtree(f) @@ -1907,6 +1753,8 @@ def smart_encode_punycode(text: str) -> str: """ ドメイン.テスト --> xn--eckwd4c7c.xn--zckzah """ + import idna + host, before, after = extract_host(text) if host is None: return text @@ -1923,6 +1771,8 @@ def smart_decode_punycode(text: str) -> str: """ xn--eckwd4c7c.xn--zckzah --> ドメイン.テスト """ + import idna + host, before, after = extract_host(text) if host is None: return text @@ -2014,6 +1864,8 @@ def make_table(rows, header, **kwargs): | row2 | row2 | +-----------+-----------+ """ + from tabulate import tabulate + # fix IndexError: list index out of range if not rows: rows = [[]] @@ -2150,6 +2002,50 @@ def human_to_bytes(filesize): raise ValueError(f'Unable to convert filesize "{filesize}" to bytes') +def integer_to_ordinal(n): + """ + Convert an integer to its ordinal representation. + + Args: + n (int): The integer to convert. + + Returns: + str: The ordinal representation of the integer. + + Examples: + >>> integer_to_ordinal(1) + '1st' + >>> integer_to_ordinal(2) + '2nd' + >>> integer_to_ordinal(3) + '3rd' + >>> integer_to_ordinal(11) + '11th' + >>> integer_to_ordinal(21) + '21st' + >>> integer_to_ordinal(101) + '101st' + """ + # Check the last digit + last_digit = n % 10 + # Check the last two digits for special cases (11th, 12th, 13th) + last_two_digits = n % 100 + + if 10 <= last_two_digits <= 20: + suffix = "th" + else: + if last_digit == 1: + suffix = "st" + elif last_digit == 2: + suffix = "nd" + elif last_digit == 3: + suffix = "rd" + else: + suffix = "th" + + return f"{n}{suffix}" + + def cpu_architecture(): """Return the CPU architecture of the current system. @@ -2163,6 +2059,8 @@ def cpu_architecture(): >>> cpu_architecture() 'amd64' """ + import platform + uname = platform.uname() arch = uname.machine.lower() if arch.startswith("aarch"): @@ -2185,6 +2083,8 @@ def os_platform(): >>> os_platform() 'linux' """ + import platform + return platform.system().lower() @@ -2210,7 +2110,7 @@ def os_platform_friendly(): tag_filter_regex = re.compile(r"[^a-z0-9]+") -def tagify(s, maxlen=None): +def tagify(s, delimiter=None, maxlen=None): """Sanitize a string into a tag-friendly format. Converts a given string to lowercase and replaces all characters not matching @@ -2229,8 +2129,10 @@ def tagify(s, maxlen=None): >>> tagify("HTTP Web Title", maxlen=8) 'http-web' """ + if delimiter is None: + delimiter = "-" ret = str(s).lower() - return tag_filter_regex.sub("-", ret)[:maxlen].strip("-") + return tag_filter_regex.sub(delimiter, ret)[:maxlen].strip(delimiter) def memory_status(): @@ -2253,6 +2155,8 @@ def memory_status(): >>> mem.percent 79.0 """ + import psutil + return psutil.virtual_memory() @@ -2275,6 +2179,8 @@ def swap_status(): >>> swap.used 2097152 """ + import psutil + return psutil.swap_memory() @@ -2297,6 +2203,8 @@ def get_size(obj, max_depth=5, seen=None): >>> get_size(my_dict, max_depth=3) 8400 """ + from collections.abc import Mapping + # If seen is not provided, initialize an empty set if seen is None: seen = set() @@ -2372,6 +2280,8 @@ def cloudcheck(ip): >>> cloudcheck("168.62.20.37") ('Azure', 'cloud', IPv4Network('168.62.0.0/19')) """ + import cloudcheck as _cloudcheck + return _cloudcheck.check(ip) @@ -2391,6 +2301,8 @@ def is_async_function(f): >>> is_async_function(foo) True """ + import inspect + return inspect.iscoroutinefunction(f) @@ -2451,6 +2363,27 @@ def get_exception_chain(e): return exception_chain +def in_exception_chain(e, exc_types): + """ + Given an Exception and a list of Exception types, returns whether any of the specified types are contained anywhere in the Exception chain. + + Args: + e (BaseException): The exception to check + exc_types (list[Exception]): Exception types to consider intentional cancellations. Default is KeyboardInterrupt + + Returns: + bool: Whether the error is the result of an intentional cancellaion + + Examples: + >>> try: + ... raise ValueError("This is a value error") + ... except Exception as e: + ... if not in_exception_chain(e, (KeyboardInterrupt, asyncio.CancelledError)): + ... raise + """ + return any([isinstance(_, exc_types) for _ in get_exception_chain(e)]) + + def get_traceback_details(e): """ Retrieves detailed information from the traceback of an exception. @@ -2469,6 +2402,8 @@ def get_traceback_details(e): ... print(f"File: {filename}, Line: {lineno}, Function: {funcname}") File: , Line: 2, Function: """ + import traceback + tb = traceback.extract_tb(e.__traceback__) last_frame = tb[-1] # Get the last frame in the traceback (the one where the exception was raised) filename = last_frame.filename @@ -2499,7 +2434,7 @@ async def cancel_tasks(tasks, ignore_errors=True): current_task = asyncio.current_task() tasks = [t for t in tasks if t != current_task] for task in tasks: - log.debug(f"Cancelling task: {task}") + # log.debug(f"Cancelling task: {task}") task.cancel() if ignore_errors: for task in tasks: @@ -2507,6 +2442,8 @@ async def cancel_tasks(tasks, ignore_errors=True): await task except BaseException as e: if not isinstance(e, asyncio.CancelledError): + import traceback + log.trace(traceback.format_exc()) @@ -2529,7 +2466,7 @@ def cancel_tasks_sync(tasks): current_task = asyncio.current_task() for task in tasks: if task != current_task: - log.debug(f"Cancelling task: {task}") + # log.debug(f"Cancelling task: {task}") task.cancel() @@ -2653,6 +2590,33 @@ async def as_completed(coros): yield task +def clean_dns_record(record): + """ + Cleans and formats a given DNS record for further processing. + + This static method converts the DNS record to text format if it's not already a string. + It also removes any trailing dots and converts the record to lowercase. + + Args: + record (str or dns.rdata.Rdata): The DNS record to clean. + + Returns: + str: The cleaned and formatted DNS record. + + Examples: + >>> clean_dns_record('www.evilcorp.com.') + 'www.evilcorp.com' + + >>> from dns.rrset import from_text + >>> record = from_text('www.evilcorp.com', 3600, 'IN', 'A', '1.2.3.4')[0] + >>> clean_dns_record(record) + '1.2.3.4' + """ + if not isinstance(record, str): + record = str(record.to_text()) + return str(record).rstrip(".").lower() + + def truncate_filename(file_path, max_length=255): """ Truncate the filename while preserving the file extension to ensure the total path length does not exceed the maximum length. @@ -2686,3 +2650,138 @@ def truncate_filename(file_path, max_length=255): new_path = directory / (truncated_stem + suffix) return new_path + + +def get_keys_in_dot_syntax(config): + """Retrieve all keys in an OmegaConf configuration in dot notation. + + This function converts an OmegaConf configuration into a list of keys + represented in dot notation. + + Args: + config (DictConfig): The OmegaConf configuration object. + + Returns: + List[str]: A list of keys in dot notation. + + Examples: + >>> config = OmegaConf.create({ + ... "web": { + ... "test": True + ... }, + ... "db": { + ... "host": "localhost", + ... "port": 5432 + ... } + ... }) + >>> get_keys_in_dot_syntax(config) + ['web.test', 'db.host', 'db.port'] + """ + from omegaconf import OmegaConf + + container = OmegaConf.to_container(config, resolve=True) + keys = [] + + def recursive_keys(d, parent_key=""): + for k, v in d.items(): + full_key = f"{parent_key}.{k}" if parent_key else k + if isinstance(v, dict): + recursive_keys(v, full_key) + else: + keys.append(full_key) + + recursive_keys(container) + return keys + + +def filter_dict(d, *key_names, fuzzy=False, exclude_keys=None, _prev_key=None): + """ + Recursively filter a dictionary based on key names. + + Args: + d (dict): The input dictionary. + *key_names: Names of keys to filter for. + fuzzy (bool): Whether to perform fuzzy matching on keys. + exclude_keys (list, None): List of keys to be excluded from the final dict. + _prev_key (str, None): For internal recursive use; the previous key in the hierarchy. + + Returns: + dict: A dictionary containing only the keys specified in key_names. + + Examples: + >>> filter_dict({"key1": "test", "key2": "asdf"}, "key2") + {"key2": "asdf"} + >>> filter_dict({"key1": "test", "key2": {"key3": "asdf"}}, "key1", "key3", exclude_keys="key2") + {'key1': 'test'} + """ + if exclude_keys is None: + exclude_keys = [] + if isinstance(exclude_keys, str): + exclude_keys = [exclude_keys] + ret = {} + if isinstance(d, dict): + for key in d: + if key in key_names or (fuzzy and any(k in key for k in key_names)): + if not any(k in exclude_keys for k in [key, _prev_key]): + ret[key] = copy.deepcopy(d[key]) + elif isinstance(d[key], list) or isinstance(d[key], dict): + child = filter_dict(d[key], *key_names, fuzzy=fuzzy, _prev_key=key, exclude_keys=exclude_keys) + if child: + ret[key] = child + return ret + + +def clean_dict(d, *key_names, fuzzy=False, exclude_keys=None, _prev_key=None): + """ + Recursively clean unwanted keys from a dictionary. + Useful for removing secrets from a config. + + Args: + d (dict): The input dictionary. + *key_names: Names of keys to remove. + fuzzy (bool): Whether to perform fuzzy matching on keys. + exclude_keys (list, None): List of keys to be excluded from removal. + _prev_key (str, None): For internal recursive use; the previous key in the hierarchy. + + Returns: + dict: A dictionary cleaned of the keys specified in key_names. + + """ + if exclude_keys is None: + exclude_keys = [] + if isinstance(exclude_keys, str): + exclude_keys = [exclude_keys] + d = copy.deepcopy(d) + if isinstance(d, dict): + for key, val in list(d.items()): + if key in key_names or (fuzzy and any(k in key for k in key_names)): + if _prev_key not in exclude_keys: + d.pop(key) + continue + d[key] = clean_dict(val, *key_names, fuzzy=fuzzy, _prev_key=key, exclude_keys=exclude_keys) + return d + + +top_ports_cache = None + + +def top_tcp_ports(n, as_string=False): + """ + Returns the top *n* TCP ports as evaluated by nmap + """ + top_ports_file = Path(__file__).parent.parent.parent / "wordlists" / "top_open_ports_nmap.txt" + + global top_ports_cache + if top_ports_cache is None: + # Read the open ports from the file + with open(top_ports_file, "r") as f: + top_ports_cache = [int(line.strip()) for line in f] + + # If n is greater than the length of the ports list, add remaining ports from range(1, 65536) + unique_ports = set(top_ports_cache) + top_ports_cache.extend([port for port in range(1, 65536) if port not in unique_ports]) + + top_ports = top_ports_cache[:n] + if as_string: + return ",".join([str(s) for s in top_ports]) + return top_ports diff --git a/bbot/core/helpers/names_generator.py b/bbot/core/helpers/names_generator.py index 3e16b446a..c0a9ef4c3 100644 --- a/bbot/core/helpers/names_generator.py +++ b/bbot/core/helpers/names_generator.py @@ -2,6 +2,7 @@ adjectives = [ "abnormal", + "accidental", "acoustic", "acrophobic", "adorable", @@ -9,6 +10,7 @@ "affectionate", "aggravated", "aggrieved", + "almighty", "anal", "atrocious", "awkward", @@ -140,6 +142,7 @@ "medicated", "mediocre", "melodramatic", + "mighty", "moist", "molten", "monstrous", @@ -188,6 +191,7 @@ "rapid_unscheduled", "raving", "reckless", + "reductive", "ripped", "sadistic", "satanic", @@ -233,7 +237,6 @@ "ticklish", "tiny", "tricky", - "tufty", "twitchy", "ugly", "unabated", @@ -578,6 +581,7 @@ "rachel", "radagast", "ralph", + "rambunctious", "randy", "raymond", "rebecca", diff --git a/bbot/core/helpers/ntlm.py b/bbot/core/helpers/ntlm.py index 8605ef34a..9d66b3ea7 100644 --- a/bbot/core/helpers/ntlm.py +++ b/bbot/core/helpers/ntlm.py @@ -5,7 +5,7 @@ import logging import collections -from bbot.core.errors import NTLMError +from bbot.errors import NTLMError log = logging.getLogger("bbot.core.helpers.ntlm") diff --git a/bbot/core/helpers/process.py b/bbot/core/helpers/process.py new file mode 100644 index 000000000..7f3a23849 --- /dev/null +++ b/bbot/core/helpers/process.py @@ -0,0 +1,71 @@ +import logging +import traceback +import threading +import multiprocessing +from multiprocessing.context import SpawnProcess + +from .misc import in_exception_chain + + +current_process = multiprocessing.current_process() + + +class BBOTThread(threading.Thread): + + default_name = "default bbot thread" + + def __init__(self, *args, **kwargs): + self.custom_name = kwargs.pop("custom_name", self.default_name) + super().__init__(*args, **kwargs) + + def run(self): + from setproctitle import setthreadtitle + + setthreadtitle(str(self.custom_name)) + super().run() + + +class BBOTProcess(SpawnProcess): + + default_name = "bbot process pool" + + def __init__(self, *args, **kwargs): + self.log_queue = kwargs.pop("log_queue", None) + self.log_level = kwargs.pop("log_level", None) + self.custom_name = kwargs.pop("custom_name", self.default_name) + super().__init__(*args, **kwargs) + self.daemon = True + + def run(self): + """ + A version of Process.run() with BBOT logging and better error handling + """ + log = logging.getLogger("bbot.core.process") + try: + if self.log_level is not None and self.log_queue is not None: + from bbot.core import CORE + + CORE.logger.setup_queue_handler(self.log_queue, self.log_level) + if self.custom_name: + from setproctitle import setproctitle + + setproctitle(str(self.custom_name)) + super().run() + except BaseException as e: + if not in_exception_chain(e, (KeyboardInterrupt,)): + log.warning(f"Error in {self.name}: {e}") + log.trace(traceback.format_exc()) + + +if current_process.name == "MainProcess": + # if this is the main bbot process, set the logger and queue for the first time + from bbot.core import CORE + from functools import partialmethod + + BBOTProcess.__init__ = partialmethod( + BBOTProcess.__init__, log_level=CORE.logger.log_level, log_queue=CORE.logger.queue + ) + +# this makes our process class the default for process pools, etc. +mp_context = multiprocessing.get_context("spawn") +mp_context.Process = BBOTProcess diff --git a/bbot/core/helpers/regex.py b/bbot/core/helpers/regex.py new file mode 100644 index 000000000..f0bee1fc0 --- /dev/null +++ b/bbot/core/helpers/regex.py @@ -0,0 +1,105 @@ +import asyncio +import regex as re +from . import misc + + +class RegexHelper: + """ + Class for misc CPU-intensive regex operations + + Offloads regex processing to other CPU cores via GIL release + thread pool + + For quick, one-off regexes, you don't need to use this helper. + Only use this helper if you're searching large bodies of text + or if your regex is CPU-intensive + """ + + def __init__(self, parent_helper): + self.parent_helper = parent_helper + + def ensure_compiled_regex(self, r): + """ + Make sure a regex has been compiled + """ + if not isinstance(r, re.Pattern): + raise ValueError("Regex must be compiled first!") + + def compile(self, *args, **kwargs): + return re.compile(*args, **kwargs) + + async def search(self, compiled_regex, *args, **kwargs): + self.ensure_compiled_regex(compiled_regex) + return await self.parent_helper.run_in_executor(compiled_regex.search, *args, **kwargs) + + async def findall(self, compiled_regex, *args, **kwargs): + self.ensure_compiled_regex(compiled_regex) + return await self.parent_helper.run_in_executor(compiled_regex.findall, *args, **kwargs) + + async def findall_multi(self, compiled_regexes, *args, threads=10, **kwargs): + """ + Same as findall() but with multiple regexes + """ + if not isinstance(compiled_regexes, dict): + raise ValueError('compiled_regexes must be a dictionary like this: {"regex_name": }') + for k, v in compiled_regexes.items(): + self.ensure_compiled_regex(v) + + tasks = {} + + def new_task(regex_name, r): + task = self.parent_helper.run_in_executor(r.findall, *args, **kwargs) + tasks[task] = regex_name + + compiled_regexes = dict(compiled_regexes) + for _ in range(threads): # Start initial batch of tasks + if compiled_regexes: # Ensure there are args to process + new_task(*compiled_regexes.popitem()) + + while tasks: # While there are tasks pending + # Wait for the first task to complete + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + for task in done: + result = task.result() + regex_name = tasks.pop(task) + yield (regex_name, result) + + if compiled_regexes: # Start a new task for each one completed, if URLs remain + new_task(*compiled_regexes.popitem()) + + async def finditer(self, compiled_regex, *args, **kwargs): + self.ensure_compiled_regex(compiled_regex) + return await self.parent_helper.run_in_executor(self._finditer, compiled_regex, *args, **kwargs) + + async def finditer_multi(self, compiled_regexes, *args, **kwargs): + """ + Same as finditer() but with multiple regexes + """ + for r in compiled_regexes: + self.ensure_compiled_regex(r) + return await self.parent_helper.run_in_executor(self._finditer_multi, compiled_regexes, *args, **kwargs) + + def _finditer_multi(self, compiled_regexes, *args, **kwargs): + matches = [] + for r in compiled_regexes: + for m in r.finditer(*args, **kwargs): + matches.append(m) + return matches + + def _finditer(self, compiled_regex, *args, **kwargs): + return list(compiled_regex.finditer(*args, **kwargs)) + + async def extract_params_html(self, *args, **kwargs): + return await self.parent_helper.run_in_executor(misc.extract_params_html, *args, **kwargs) + + async def extract_emails(self, *args, **kwargs): + return await self.parent_helper.run_in_executor(misc.extract_emails, *args, **kwargs) + + async def search_dict_values(self, *args, **kwargs): + def _search_dict_values(*_args, **_kwargs): + return list(misc.search_dict_values(*_args, **_kwargs)) + + return await self.parent_helper.run_in_executor(_search_dict_values, *args, **kwargs) + + async def recursive_decode(self, *args, **kwargs): + return await self.parent_helper.run_in_executor(misc.recursive_decode, *args, **kwargs) diff --git a/bbot/core/helpers/regexes.py b/bbot/core/helpers/regexes.py index 890cedf40..0c01ff022 100644 --- a/bbot/core/helpers/regexes.py +++ b/bbot/core/helpers/regexes.py @@ -1,4 +1,4 @@ -import re +import regex as re from collections import OrderedDict # for extracting words from strings @@ -114,11 +114,29 @@ scan_name_regex = re.compile(r"[a-z]{3,20}_[a-z]{3,20}") -# For use with extract_params_html helper -input_tag_regex = re.compile(r"]+?name=[\"\'](\w+)[\"\']") +# For use with excavate paramaters extractor +input_tag_regex = re.compile( + r"]+?name=[\"\']?([\.$\w]+)[\"\']?(?:[^>]*?value=[\"\']([=+\/\w]*)[\"\'])?[^>]*>" +) jquery_get_regex = re.compile(r"url:\s?[\"\'].+?\?(\w+)=") jquery_post_regex = re.compile(r"\$.post\([\'\"].+[\'\"].+\{(.+)\}") -a_tag_regex = re.compile(r"]*href=[\"\'][^\"\'?>]*\?([^&\"\'=]+)") +a_tag_regex = re.compile(r"]*href=[\"\']([^\"\'?>]*)\?([^&\"\'=]+)=([^&\"\'=]+)") +img_tag_regex = re.compile(r"]*src=[\"\']([^\"\'?>]*)\?([^&\"\'=]+)=([^&\"\'=]+)") +get_form_regex = re.compile( + r"]+(?:action=[\"']?([^\s\'\"]+)[\"\']?)?[^>]*method=[\"']?[gG][eE][tT][\"']?[^>]*>([\s\S]*?)<\/form>", + re.DOTALL, +) +post_form_regex = re.compile( + r"]+(?:action=[\"']?([^\s\'\"]+)[\"\']?)?[^>]*method=[\"']?[pP][oO][sS][tT][\"']?[^>]*>([\s\S]*?)<\/form>", + re.DOTALL, +) +select_tag_regex = re.compile( + r"]+?name=[\"\']?(\w+)[\"\']?[^>]*>(?:\s*]*?value=[\"\'](\w*)[\"\']?[^>]*>)?" +) +textarea_tag_regex = re.compile( + r']*\bname=["\']?(\w+)["\']?[^>]*>(.*?)', re.IGNORECASE | re.DOTALL +) +tag_attribute_regex = re.compile(r"<[^>]*(?:href|src)\s*=\s*[\"\']([^\"\']+)[\"\'][^>]*>") valid_netloc = r"[^\s!@#$%^&()=/?\\'\";~`<>]+" @@ -130,3 +148,7 @@ _extract_host_regex = r"(?:[a-z0-9]{1,20}://)?(?:[^?]*@)?(" + valid_netloc + ")" extract_host_regex = re.compile(_extract_host_regex, re.I) + +# for use in recursive_decode() +encoded_regex = re.compile(r"%[0-9a-fA-F]{2}|\\u[0-9a-fA-F]{4}|\\U[0-9a-fA-F]{8}|\\[ntrbv]") +backslash_regex = re.compile(r"(?P\\+)(?P[ntrvb])") diff --git a/bbot/core/helpers/validators.py b/bbot/core/helpers/validators.py index 0384a876e..417683adf 100644 --- a/bbot/core/helpers/validators.py +++ b/bbot/core/helpers/validators.py @@ -5,7 +5,7 @@ from contextlib import suppress from bbot.core.helpers import regexes -from bbot.core.errors import ValidationError +from bbot.errors import ValidationError from bbot.core.helpers.url import parse_url, hash_url from bbot.core.helpers.misc import smart_encode_punycode, split_host_port, make_netloc, is_ip @@ -129,19 +129,6 @@ def validate_host(host: Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address] raise ValidationError(f'Invalid hostname: "{host}"') -@validator -def validate_url(url: str): - return validate_url_parsed(url).geturl() - - -@validator -def validate_url_parsed(url: str): - url = str(url).strip() - if not any(r.match(url) for r in regexes.event_type_regexes["URL"]): - raise ValidationError(f'Invalid URL: "{url}"') - return clean_url(url) - - @validator def validate_severity(severity: str): severity = str(severity).strip().upper() @@ -158,7 +145,7 @@ def validate_email(email: str): raise ValidationError(f'Invalid email: "{email}"') -def clean_url(url: str): +def clean_url(url: str, url_querystring_remove=True): """ Cleans and normalizes a URL. This function removes the query string and fragment, lowercases the netloc, and removes redundant port numbers. @@ -180,7 +167,11 @@ def clean_url(url: str): ParseResult(scheme='http', netloc='evilcorp.com', path='/api', params='', query='', fragment='') """ parsed = parse_url(url) - parsed = parsed._replace(netloc=str(parsed.netloc).lower(), fragment="", query="") + + if url_querystring_remove: + parsed = parsed._replace(netloc=str(parsed.netloc).lower(), fragment="", query="") + else: + parsed = parsed._replace(netloc=str(parsed.netloc).lower(), fragment="") try: scheme = parsed.scheme except ValueError: @@ -252,6 +243,19 @@ def _collapse_urls(urls, threshold=10): yield from new_urls +@validator +def validate_url(url: str): + return validate_url_parsed(url).geturl() + + +@validator +def validate_url_parsed(url: str): + url = str(url).strip() + if not any(r.match(url) for r in regexes.event_type_regexes["URL"]): + raise ValidationError(f'Invalid URL: "{url}"') + return clean_url(url) + + def soft_validate(s, t): """ Softly validates a given string against a specified type. This function returns a boolean @@ -292,3 +296,22 @@ def is_email(email): return True except ValueError: return False + + +class Validators: + + def __init__(self, parent_helper): + self.parent_helper = parent_helper + + def clean_url(self, url: str): + url_querystring_remove = self.parent_helper.config.get("url_querystring_remove", True) + return clean_url(url, url_querystring_remove=url_querystring_remove) + + def validate_url_parsed(self, url: str): + """ + This version is necessary so that it can be config-aware when needed, to avoid a chicken-egg situation. Currently this is only used by the base event class to sanitize URLs + """ + url = str(url).strip() + if not any(r.match(url) for r in regexes.event_type_regexes["URL"]): + raise ValidationError(f'Invalid URL: "{url}"') + return self.clean_url(url) diff --git a/bbot/core/helpers/web/__init__.py b/bbot/core/helpers/web/__init__.py new file mode 100644 index 000000000..8fcf82abb --- /dev/null +++ b/bbot/core/helpers/web/__init__.py @@ -0,0 +1 @@ +from .web import WebHelper diff --git a/bbot/core/helpers/web/client.py b/bbot/core/helpers/web/client.py new file mode 100644 index 000000000..cd925730d --- /dev/null +++ b/bbot/core/helpers/web/client.py @@ -0,0 +1,93 @@ +import httpx +import logging +from httpx._models import Cookies + +log = logging.getLogger("bbot.core.helpers.web.client") + + +class DummyCookies(Cookies): + def extract_cookies(self, *args, **kwargs): + pass + + +class BBOTAsyncClient(httpx.AsyncClient): + """ + A subclass of httpx.AsyncClient tailored with BBOT-specific configurations and functionalities. + This class provides rate limiting, logging, configurable timeouts, user-agent customization, custom + headers, and proxy settings. Additionally, it allows the disabling of cookies, making it suitable + for use across an entire scan. + + Attributes: + _bbot_scan (object): BBOT scan object containing configuration details. + _persist_cookies (bool): Flag to determine whether cookies should be persisted across requests. + + Examples: + >>> async with BBOTAsyncClient(_bbot_scan=bbot_scan_object) as client: + >>> response = await client.request("GET", "https://example.com") + >>> print(response.status_code) + 200 + """ + + @classmethod + def from_config(cls, config, target, *args, **kwargs): + kwargs["_config"] = config + kwargs["_target"] = target + web_config = config.get("web", {}) + retries = kwargs.pop("retries", web_config.get("http_retries", 1)) + ssl_verify = web_config.get("ssl_verify", False) + if ssl_verify is False: + from .ssl_context import ssl_context_noverify + + ssl_verify = ssl_context_noverify + kwargs["transport"] = httpx.AsyncHTTPTransport(retries=retries, verify=ssl_verify) + kwargs["verify"] = ssl_verify + return cls(*args, **kwargs) + + def __init__(self, *args, **kwargs): + self._config = kwargs.pop("_config") + self._target = kwargs.pop("_target") + + self._web_config = self._config.get("web", {}) + http_debug = self._web_config.get("debug", None) + if http_debug: + log.trace(f"Creating AsyncClient: {args}, {kwargs}") + + self._persist_cookies = kwargs.pop("persist_cookies", True) + + # timeout + http_timeout = self._web_config.get("http_timeout", 20) + if not "timeout" in kwargs: + kwargs["timeout"] = http_timeout + + # headers + headers = kwargs.get("headers", None) + if headers is None: + headers = {} + # user agent + user_agent = self._web_config.get("user_agent", "BBOT") + if "User-Agent" not in headers: + headers["User-Agent"] = user_agent + kwargs["headers"] = headers + # proxy + proxies = self._web_config.get("http_proxy", None) + kwargs["proxies"] = proxies + + super().__init__(*args, **kwargs) + if not self._persist_cookies: + self._cookies = DummyCookies() + + def build_request(self, *args, **kwargs): + request = super().build_request(*args, **kwargs) + # add custom headers if the URL is in-scope + # TODO: re-enable this + if self._target.in_scope(str(request.url)): + for hk, hv in self._web_config.get("http_headers", {}).items(): + # don't clobber headers + if hk not in request.headers: + request.headers[hk] = hv + return request + + def _merge_cookies(self, cookies): + if self._persist_cookies: + return super()._merge_cookies(cookies) + return cookies diff --git a/bbot/core/helpers/web/engine.py b/bbot/core/helpers/web/engine.py new file mode 100644 index 000000000..8249eb33f --- /dev/null +++ b/bbot/core/helpers/web/engine.py @@ -0,0 +1,251 @@ +import ssl +import anyio +import httpx +import asyncio +import logging +import traceback +from socksio.exceptions import SOCKSError +from contextlib import asynccontextmanager + +from bbot.core.engine import EngineServer +from bbot.core.helpers.misc import bytes_to_human, human_to_bytes, get_exception_chain + +log = logging.getLogger("bbot.core.helpers.web.engine") + + +class HTTPEngine(EngineServer): + + CMDS = { + 0: "request", + 1: "request_batch", + 2: "request_custom_batch", + 3: "download", + } + + client_only_options = ( + "retries", + "max_redirects", + ) + + def __init__(self, socket_path, target, config={}): + super().__init__(socket_path) + self.target = target + self.config = config + self.web_config = self.config.get("web", {}) + self.http_debug = self.web_config.get("http_debug", False) + self._ssl_context_noverify = None + self.web_client = self.AsyncClient(persist_cookies=False) + + def AsyncClient(self, *args, **kwargs): + from .client import BBOTAsyncClient + + return BBOTAsyncClient.from_config(self.config, self.target, *args, **kwargs) + + async def request(self, *args, **kwargs): + raise_error = kwargs.pop("raise_error", False) + # TODO: use this + cache_for = kwargs.pop("cache_for", None) # noqa + + client = kwargs.get("client", self.web_client) + + # allow vs follow, httpx why?? + allow_redirects = kwargs.pop("allow_redirects", None) + if allow_redirects is not None and "follow_redirects" not in kwargs: + kwargs["follow_redirects"] = allow_redirects + + # in case of URL only, assume GET request + if len(args) == 1: + kwargs["url"] = args[0] + args = [] + + url = kwargs.get("url", "") + + if not args and "method" not in kwargs: + kwargs["method"] = "GET" + + client_kwargs = {} + for k in list(kwargs): + if k in self.client_only_options: + v = kwargs.pop(k) + client_kwargs[k] = v + + if client_kwargs: + client = self.AsyncClient(**client_kwargs) + + async with self._acatch(url, raise_error): + if self.http_debug: + logstr = f"Web request: {str(args)}, {str(kwargs)}" + log.trace(logstr) + response = await client.request(*args, **kwargs) + if self.http_debug: + log.trace( + f"Web response from {url}: {response} (Length: {len(response.content)}) headers: {response.headers}" + ) + return response + + async def request_batch(self, urls, *args, threads=10, **kwargs): + tasks = {} + + urls = list(urls) + + def new_task(): + if urls: + url = urls.pop(0) + task = asyncio.create_task(self.request(url, *args, **kwargs)) + tasks[task] = url + + for _ in range(threads): # Start initial batch of tasks + new_task() + + while tasks: # While there are tasks pending + # Wait for the first task to complete + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + for task in done: + response = task.result() + url = tasks.pop(task) + yield (url, response) + new_task() + + async def request_custom_batch(self, urls_and_kwargs, threads=10): + tasks = {} + urls_and_kwargs = list(urls_and_kwargs) + + def new_task(): + if urls_and_kwargs: # Ensure there are args to process + url, kwargs, custom_tracker = urls_and_kwargs.pop(0) + task = asyncio.create_task(self.request(url, **kwargs)) + tasks[task] = (url, kwargs, custom_tracker) + + for _ in range(threads): # Start initial batch of tasks + new_task() + + while tasks: # While there are tasks pending + # Wait for the first task to complete + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + for task in done: + response = task.result() + url, kwargs, custom_tracker = tasks.pop(task) + yield (url, kwargs, custom_tracker, response) + new_task() + + async def download(self, url, **kwargs): + warn = kwargs.pop("warn", True) + filename = kwargs.pop("filename") + raise_error = kwargs.get("raise_error", False) + try: + content, response = await self.stream_request(url, **kwargs) + log.debug(f"Download result: HTTP {response.status_code}") + response.raise_for_status() + with open(filename, "wb") as f: + f.write(content) + return filename + except httpx.HTTPError as e: + log_fn = log.verbose + if warn: + log_fn = log.warning + log_fn(f"Failed to download {url}: {e}") + if raise_error: + raise + + async def stream_request(self, url, **kwargs): + follow_redirects = kwargs.pop("follow_redirects", True) + max_size = kwargs.pop("max_size", None) + raise_error = kwargs.pop("raise_error", False) + if max_size is not None: + max_size = human_to_bytes(max_size) + kwargs["follow_redirects"] = follow_redirects + if not "method" in kwargs: + kwargs["method"] = "GET" + try: + total_size = 0 + chunk_size = 8192 + chunks = [] + + async with self._acatch(url, raise_error=True), self.web_client.stream(url=url, **kwargs) as response: + agen = response.aiter_bytes(chunk_size=chunk_size) + async for chunk in agen: + _chunk_size = len(chunk) + if max_size is not None and total_size + _chunk_size > max_size: + log.verbose( + f"Size of response from {url} exceeds {bytes_to_human(max_size)}, file will be truncated" + ) + agen.aclose() + break + total_size += _chunk_size + chunks.append(chunk) + return b"".join(chunks), response + except httpx.HTTPError as e: + self.log.debug(f"Error requesting {url}: {e}") + if raise_error: + raise + + def ssl_context_noverify(self): + if self._ssl_context_noverify is None: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + ssl_context.options &= ~ssl.OP_NO_SSLv2 & ~ssl.OP_NO_SSLv3 + ssl_context.set_ciphers("ALL:@SECLEVEL=0") + ssl_context.options |= 0x4 # Add the OP_LEGACY_SERVER_CONNECT option + self._ssl_context_noverify = ssl_context + return self._ssl_context_noverify + + @asynccontextmanager + async def _acatch(self, url, raise_error): + """ + Asynchronous context manager to handle various httpx errors during a request. + + Yields: + None + + Note: + This function is internal and should generally not be used directly. + `url`, `args`, `kwargs`, and `raise_error` should be in the same context as this function. + """ + try: + yield + except httpx.TimeoutException: + if raise_error: + raise + else: + log.verbose(f"HTTP timeout to URL: {url}") + except httpx.ConnectError: + if raise_error: + raise + else: + log.debug(f"HTTP connect failed to URL: {url}") + except httpx.HTTPError as e: + if raise_error: + raise + else: + log.trace(f"Error with request to URL: {url}: {e}") + log.trace(traceback.format_exc()) + except ssl.SSLError as e: + msg = f"SSL error with request to URL: {url}: {e}" + if raise_error: + raise httpx.RequestError(msg) + else: + log.trace(msg) + log.trace(traceback.format_exc()) + except anyio.EndOfStream as e: + msg = f"AnyIO error with request to URL: {url}: {e}" + if raise_error: + raise httpx.RequestError(msg) + else: + log.trace(msg) + log.trace(traceback.format_exc()) + except SOCKSError as e: + msg = f"SOCKS error with request to URL: {url}: {e}" + if raise_error: + raise httpx.RequestError(msg) + else: + log.trace(msg) + log.trace(traceback.format_exc()) + except BaseException as e: + # don't log if the error is the result of an intentional cancellation + if not any(isinstance(_e, asyncio.exceptions.CancelledError) for _e in get_exception_chain(e)): + log.trace(f"Unhandled exception with request to URL: {url}: {e}") + log.trace(traceback.format_exc()) + raise diff --git a/bbot/core/helpers/web/ssl_context.py b/bbot/core/helpers/web/ssl_context.py new file mode 100644 index 000000000..fabe4188f --- /dev/null +++ b/bbot/core/helpers/web/ssl_context.py @@ -0,0 +1,8 @@ +import ssl + +ssl_context_noverify = ssl.create_default_context() +ssl_context_noverify.check_hostname = False +ssl_context_noverify.verify_mode = ssl.CERT_NONE +ssl_context_noverify.options &= ~ssl.OP_NO_SSLv2 & ~ssl.OP_NO_SSLv3 +ssl_context_noverify.set_ciphers("ALL:@SECLEVEL=0") +ssl_context_noverify.options |= 0x4 # Add the OP_LEGACY_SERVER_CONNECT option diff --git a/bbot/core/helpers/web.py b/bbot/core/helpers/web/web.py similarity index 58% rename from bbot/core/helpers/web.py rename to bbot/core/helpers/web/web.py index f560ad791..4f38c3388 100644 --- a/bbot/core/helpers/web.py +++ b/bbot/core/helpers/web/web.py @@ -1,109 +1,30 @@ import re -import ssl -import anyio -import httpx -import asyncio import logging import warnings import traceback from pathlib import Path from bs4 import BeautifulSoup -from contextlib import asynccontextmanager - -from httpx._models import Cookies -from socksio.exceptions import SOCKSError +from bbot.core.engine import EngineClient from bbot.core.helpers.misc import truncate_filename -from bbot.core.errors import WordlistError, CurlError -from bbot.core.helpers.ratelimiter import RateLimiter +from bbot.errors import WordlistError, CurlError, WebError from bs4 import MarkupResemblesLocatorWarning from bs4.builder import XMLParsedAsHTMLWarning +from .engine import HTTPEngine + warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning) log = logging.getLogger("bbot.core.helpers.web") -class DummyCookies(Cookies): - def extract_cookies(self, *args, **kwargs): - pass - - -class BBOTAsyncClient(httpx.AsyncClient): - """ - A subclass of httpx.AsyncClient tailored with BBOT-specific configurations and functionalities. - This class provides rate limiting, logging, configurable timeouts, user-agent customization, custom - headers, and proxy settings. Additionally, it allows the disabling of cookies, making it suitable - for use across an entire scan. +class WebHelper(EngineClient): - Attributes: - _bbot_scan (object): BBOT scan object containing configuration details. - _rate_limiter (RateLimiter): A rate limiter object to limit web requests. - _persist_cookies (bool): Flag to determine whether cookies should be persisted across requests. + SERVER_CLASS = HTTPEngine + ERROR_CLASS = WebError - Examples: - >>> async with BBOTAsyncClient(_bbot_scan=bbot_scan_object) as client: - >>> response = await client.request("GET", "https://example.com") - >>> print(response.status_code) - 200 - """ - - def __init__(self, *args, **kwargs): - self._bbot_scan = kwargs.pop("_bbot_scan") - web_requests_per_second = self._bbot_scan.config.get("web_requests_per_second", 100) - self._rate_limiter = RateLimiter(web_requests_per_second, "Web") - - http_debug = self._bbot_scan.config.get("http_debug", None) - if http_debug: - log.trace(f"Creating AsyncClient: {args}, {kwargs}") - - self._persist_cookies = kwargs.pop("persist_cookies", True) - - # timeout - http_timeout = self._bbot_scan.config.get("http_timeout", 20) - if not "timeout" in kwargs: - kwargs["timeout"] = http_timeout - - # headers - headers = kwargs.get("headers", None) - if headers is None: - headers = {} - # user agent - user_agent = self._bbot_scan.config.get("user_agent", "BBOT") - if "User-Agent" not in headers: - headers["User-Agent"] = user_agent - kwargs["headers"] = headers - # proxy - proxies = self._bbot_scan.config.get("http_proxy", None) - kwargs["proxies"] = proxies - - super().__init__(*args, **kwargs) - if not self._persist_cookies: - self._cookies = DummyCookies() - - async def request(self, *args, **kwargs): - async with self._rate_limiter: - return await super().request(*args, **kwargs) - - def build_request(self, *args, **kwargs): - request = super().build_request(*args, **kwargs) - # add custom headers if the URL is in-scope - if self._bbot_scan.in_scope(str(request.url)): - for hk, hv in self._bbot_scan.config.get("http_headers", {}).items(): - # don't clobber headers - if hk not in request.headers: - request.headers[hk] = hv - return request - - def _merge_cookies(self, cookies): - if self._persist_cookies: - return super()._merge_cookies(cookies) - return cookies - - -class WebHelper: """ Main utility class for managing HTTP operations in BBOT. It serves as a wrapper around the BBOTAsyncClient, which itself is a subclass of httpx.AsyncClient. The class provides functionalities to make HTTP requests, @@ -127,26 +48,21 @@ class WebHelper: >>> filename = await self.helpers.wordlist("https://www.evilcorp.com/wordlist.txt") """ - client_only_options = ( - "retries", - "max_redirects", - ) - def __init__(self, parent_helper): self.parent_helper = parent_helper - self.http_debug = self.parent_helper.config.get("http_debug", False) - self._ssl_context_noverify = None - self.ssl_verify = self.parent_helper.config.get("ssl_verify", False) - if self.ssl_verify is False: - self.ssl_verify = self.ssl_context_noverify() - self.web_client = self.AsyncClient(persist_cookies=False) + self.preset = self.parent_helper.preset + self.config = self.preset.config + self.web_config = self.config.get("web", {}) + self.web_spider_depth = self.web_config.get("spider_depth", 1) + self.web_spider_distance = self.web_config.get("spider_distance", 0) + self.target = self.preset.target + self.ssl_verify = self.config.get("ssl_verify", False) + super().__init__(server_kwargs={"config": self.config, "target": self.parent_helper.preset.target.radix_only}) def AsyncClient(self, *args, **kwargs): - kwargs["_bbot_scan"] = self.parent_helper.scan - retries = kwargs.pop("retries", self.parent_helper.config.get("http_retries", 1)) - kwargs["transport"] = httpx.AsyncHTTPTransport(retries=retries, verify=self.ssl_verify) - kwargs["verify"] = self.ssl_verify - return BBOTAsyncClient(*args, **kwargs) + from .client import BBOTAsyncClient + + return BBOTAsyncClient.from_config(self.config, self.target, *args, persist_cookies=False, **kwargs) async def request(self, *args, **kwargs): """ @@ -192,47 +108,49 @@ async def request(self, *args, **kwargs): Note: If the web request fails, it will return None unless `raise_error` is `True`. """ + return await self.run_and_return("request", *args, **kwargs) - raise_error = kwargs.pop("raise_error", False) - # TODO: use this - cache_for = kwargs.pop("cache_for", None) # noqa + async def request_batch(self, urls, *args, **kwargs): + """ + Given a list of URLs, request them in parallel and yield responses as they come in. - client = kwargs.get("client", self.web_client) + Args: + urls (list[str]): List of URLs to visit + *args: Positional arguments to pass through to httpx + **kwargs: Keyword arguments to pass through to httpx - # allow vs follow, httpx why?? - allow_redirects = kwargs.pop("allow_redirects", None) - if allow_redirects is not None and "follow_redirects" not in kwargs: - kwargs["follow_redirects"] = allow_redirects + Examples: + >>> async for url, response in self.helpers.request_batch(urls, headers={"X-Test": "Test"}): + >>> if response is not None and response.status_code == 200: + >>> self.hugesuccess(response) + """ + async for _ in self.run_and_yield("request_batch", urls, *args, **kwargs): + yield _ - # in case of URL only, assume GET request - if len(args) == 1: - kwargs["url"] = args[0] - args = [] + async def request_custom_batch(self, urls_and_kwargs): + """ + Make web requests in parallel with custom options for each request. Yield responses as they come in. - url = kwargs.get("url", "") + Similar to `request_batch` except it allows individual arguments for each URL. - if not args and "method" not in kwargs: - kwargs["method"] = "GET" - - client_kwargs = {} - for k in list(kwargs): - if k in self.client_only_options: - v = kwargs.pop(k) - client_kwargs[k] = v - - if client_kwargs: - client = self.AsyncClient(**client_kwargs) - - async with self._acatch(url, raise_error): - if self.http_debug: - logstr = f"Web request: {str(args)}, {str(kwargs)}" - log.trace(logstr) - response = await client.request(*args, **kwargs) - if self.http_debug: - log.trace( - f"Web response from {url}: {response} (Length: {len(response.content)}) headers: {response.headers}" - ) - return response + Args: + urls_and_kwargs (list[tuple]): List of tuples in the format: (url, kwargs, custom_tracker) + where custom_tracker is an optional value for your own internal use. You may use it to + help correlate requests, etc. + + Examples: + >>> urls_and_kwargs = [ + >>> ("http://evilcorp.com/1", {"method": "GET"}, "request-1"), + >>> ("http://evilcorp.com/2", {"method": "POST"}, "request-2"), + >>> ] + >>> async for url, kwargs, custom_tracker, response in self.helpers.request_custom_batch( + >>> urls_and_kwargs + >>> ): + >>> if response is not None and response.status_code == 200: + >>> self.hugesuccess(response) + """ + async for _ in self.run_and_yield("request_custom_batch", urls_and_kwargs): + yield _ async def download(self, url, **kwargs): """ @@ -259,57 +177,21 @@ async def download(self, url, **kwargs): """ success = False filename = kwargs.pop("filename", self.parent_helper.cache_filename(url)) - filename = truncate_filename(filename) - follow_redirects = kwargs.pop("follow_redirects", True) + filename = truncate_filename(Path(filename).resolve()) + kwargs["filename"] = filename max_size = kwargs.pop("max_size", None) - warn = kwargs.pop("warn", True) - raise_error = kwargs.pop("raise_error", False) if max_size is not None: max_size = self.parent_helper.human_to_bytes(max_size) + kwargs["max_size"] = max_size cache_hrs = float(kwargs.pop("cache_hrs", -1)) - total_size = 0 - chunk_size = 8192 - log.debug(f"Downloading file from {url} with cache_hrs={cache_hrs}") if cache_hrs > 0 and self.parent_helper.is_cached(url): log.debug(f"{url} is cached at {self.parent_helper.cache_filename(url)}") success = True else: - # kwargs["raise_error"] = True - # kwargs["stream"] = True - kwargs["follow_redirects"] = follow_redirects - if not "method" in kwargs: - kwargs["method"] = "GET" - try: - async with self._acatch(url, raise_error=True), self.AsyncClient().stream( - url=url, **kwargs - ) as response: - status_code = getattr(response, "status_code", 0) - log.debug(f"Download result: HTTP {status_code}") - if status_code != 0: - response.raise_for_status() - with open(filename, "wb") as f: - agen = response.aiter_bytes(chunk_size=chunk_size) - async for chunk in agen: - if max_size is not None and total_size + chunk_size > max_size: - log.verbose( - f"Filesize of {url} exceeds {self.parent_helper.bytes_to_human(max_size)}, file will be truncated" - ) - agen.aclose() - break - total_size += chunk_size - f.write(chunk) - success = True - except httpx.HTTPError as e: - log_fn = log.verbose - if warn: - log_fn = log.warning - log_fn(f"Failed to download {url}: {e}") - if raise_error: - raise - return + success = await self.run_and_return("download", url, **kwargs) if success: - return filename.resolve() + return filename async def wordlist(self, path, lines=None, **kwargs): """ @@ -474,15 +356,15 @@ async def curl(self, *args, **kwargs): if ignore_bbot_global_settings: log.debug("ignore_bbot_global_settings enabled. Global settings will not be applied") else: - http_timeout = self.parent_helper.config.get("http_timeout", 20) - user_agent = self.parent_helper.config.get("user_agent", "BBOT") + http_timeout = self.parent_helper.web_config.get("http_timeout", 20) + user_agent = self.parent_helper.web_config.get("user_agent", "BBOT") if "User-Agent" not in headers: headers["User-Agent"] = user_agent # only add custom headers if the URL is in-scope - if self.parent_helper.scan.in_scope(url): - for hk, hv in self.parent_helper.scan.config.get("http_headers", {}).items(): + if self.parent_helper.preset.in_scope(url): + for hk, hv in self.web_config.get("http_headers", {}).items(): headers[hk] = hv # add the timeout @@ -540,39 +422,6 @@ async def curl(self, *args, **kwargs): output = (await self.parent_helper.run(curl_command)).stdout return output - def is_spider_danger(self, source_event, url): - """ - Determines whether visiting a URL could potentially trigger a web-spider-like happening. - - This function assesses the depth and distance of a URL in relation to the parent helper's - configuration settings for web spidering. If the URL exceeds the specified depth or distance, - the function returns True, indicating a possible web-spider risk. - - Args: - source_event: The source event object that discovered the URL. - url (str): The URL to evaluate for web-spider risk. - - Returns: - bool: True if visiting the URL might trigger a web-spider-like event, False otherwise. - - Todo: - - Write tests for this function - - Examples: - >>> is_spider_danger(source_event_obj, "https://example.com/subpage") - True - - >>> is_spider_danger(source_event_obj, "https://example.com/") - False - """ - url_depth = self.parent_helper.url_depth(url) - web_spider_depth = self.parent_helper.scan.config.get("web_spider_depth", 1) - spider_distance = getattr(source_event, "web_spider_distance", 0) + 1 - web_spider_distance = self.parent_helper.scan.config.get("web_spider_distance", 0) - if (url_depth > web_spider_depth) or (spider_distance > web_spider_distance): - return True - return False - def beautifulsoup( self, markup, @@ -631,120 +480,102 @@ def beautifulsoup( log.debug(f"Error parsing beautifulsoup: {e}") return False - def ssl_context_noverify(self): - if self._ssl_context_noverify is None: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - ssl_context.options &= ~ssl.OP_NO_SSLv2 & ~ssl.OP_NO_SSLv3 - ssl_context.set_ciphers("ALL:@SECLEVEL=0") - ssl_context.options |= 0x4 # Add the OP_LEGACY_SERVER_CONNECT option - self._ssl_context_noverify = ssl_context - return self._ssl_context_noverify - - @asynccontextmanager - async def _acatch(self, url, raise_error): - """ - Asynchronous context manager to handle various httpx errors during a request. - - Yields: - None + user_keywords = [re.compile(r, re.I) for r in ["user", "login", "email"]] + pass_keywords = [re.compile(r, re.I) for r in ["pass"]] - Note: - This function is internal and should generally not be used directly. - `url`, `args`, `kwargs`, and `raise_error` should be in the same context as this function. + def is_login_page(self, html): """ - try: - yield - except httpx.TimeoutException: - if raise_error: - raise - else: - log.verbose(f"HTTP timeout to URL: {url}") - except httpx.ConnectError: - if raise_error: - raise - else: - log.debug(f"HTTP connect failed to URL: {url}") - except httpx.HTTPError as e: - if raise_error: - raise - else: - log.trace(f"Error with request to URL: {url}: {e}") - log.trace(traceback.format_exc()) - except ssl.SSLError as e: - msg = f"SSL error with request to URL: {url}: {e}" - if raise_error: - raise httpx.RequestError(msg) - else: - log.trace(msg) - log.trace(traceback.format_exc()) - except anyio.EndOfStream as e: - msg = f"AnyIO error with request to URL: {url}: {e}" - if raise_error: - raise httpx.RequestError(msg) - else: - log.trace(msg) - log.trace(traceback.format_exc()) - except SOCKSError as e: - msg = f"SOCKS error with request to URL: {url}: {e}" - if raise_error: - raise httpx.RequestError(msg) - else: - log.trace(msg) - log.trace(traceback.format_exc()) - except BaseException as e: - # don't log if the error is the result of an intentional cancellation - if not any( - isinstance(_e, asyncio.exceptions.CancelledError) for _e in self.parent_helper.get_exception_chain(e) - ): - log.trace(f"Unhandled exception with request to URL: {url}: {e}") - log.trace(traceback.format_exc()) - raise - + Determines if the provided HTML content contains a login page. -user_keywords = [re.compile(r, re.I) for r in ["user", "login", "email"]] -pass_keywords = [re.compile(r, re.I) for r in ["pass"]] + This function parses the HTML to search for forms with input fields typically used for + authentication. If it identifies password fields or a combination of username and password + fields, it returns True. + Args: + html (str): The HTML content to analyze. -def is_login_page(html): - """ - Determines if the provided HTML content contains a login page. + Returns: + bool: True if the HTML contains a login page, otherwise False. - This function parses the HTML to search for forms with input fields typically used for - authentication. If it identifies password fields or a combination of username and password - fields, it returns True. + Examples: + >>> is_login_page('
') + True - Args: - html (str): The HTML content to analyze. + >>> is_login_page('
') + False + """ + try: + soup = BeautifulSoup(html, "html.parser") + except Exception as e: + log.debug(f"Error parsing html: {e}") + return False - Returns: - bool: True if the HTML contains a login page, otherwise False. + forms = soup.find_all("form") - Examples: - >>> is_login_page('
') - True + # first, check for obvious password fields + for form in forms: + if form.find_all("input", {"type": "password"}): + return True - >>> is_login_page('
') - False - """ - try: - soup = BeautifulSoup(html, "html.parser") - except Exception as e: - log.debug(f"Error parsing html: {e}") + # next, check for forms that have both a user-like and password-like field + for form in forms: + user_fields = sum(bool(form.find_all("input", {"name": r})) for r in self.user_keywords) + pass_fields = sum(bool(form.find_all("input", {"name": r})) for r in self.pass_keywords) + if user_fields and pass_fields: + return True return False - forms = soup.find_all("form") - - # first, check for obvious password fields - for form in forms: - if form.find_all("input", {"type": "password"}): - return True + def response_to_json(self, response): + """ + Convert web response to JSON object, similar to the output of `httpx -irr -json` + """ - # next, check for forms that have both a user-like and password-like field - for form in forms: - user_fields = sum(bool(form.find_all("input", {"name": r})) for r in user_keywords) - pass_fields = sum(bool(form.find_all("input", {"name": r})) for r in pass_keywords) - if user_fields and pass_fields: - return True - return False + if response is None: + return + + import mmh3 + from datetime import datetime + from hashlib import md5, sha256 + from bbot.core.helpers.misc import tagify, urlparse, split_host_port, smart_decode + + request = response.request + url = str(request.url) + parsed_url = urlparse(url) + netloc = parsed_url.netloc + scheme = parsed_url.scheme.lower() + host, port = split_host_port(f"{scheme}://{netloc}") + + raw_headers = "\r\n".join([f"{k}: {v}" for k, v in response.headers.items()]) + raw_headers_encoded = raw_headers.encode() + + headers = {} + for k, v in response.headers.items(): + k = tagify(k, delimiter="_") + headers[k] = v + + j = { + "timestamp": datetime.now().isoformat(), + "hash": { + "body_md5": md5(response.content).hexdigest(), + "body_mmh3": mmh3.hash(response.content), + "body_sha256": sha256(response.content).hexdigest(), + # "body_simhash": "TODO", + "header_md5": md5(raw_headers_encoded).hexdigest(), + "header_mmh3": mmh3.hash(raw_headers_encoded), + "header_sha256": sha256(raw_headers_encoded).hexdigest(), + # "header_simhash": "TODO", + }, + "header": headers, + "body": smart_decode(response.content), + "content_type": headers.get("content_type", "").split(";")[0].strip(), + "url": url, + "host": str(host), + "port": port, + "scheme": scheme, + "method": response.request.method, + "path": parsed_url.path, + "raw_header": raw_headers, + "status_code": response.status_code, + } + + return j diff --git a/bbot/core/helpers/wordcloud.py b/bbot/core/helpers/wordcloud.py index 26d050406..fbd4e7593 100644 --- a/bbot/core/helpers/wordcloud.py +++ b/bbot/core/helpers/wordcloud.py @@ -322,7 +322,7 @@ def json(self, limit=None): @property def default_filename(self): - return self.parent_helper.scan.home / f"wordcloud.tsv" + return self.parent_helper.preset.scan.home / f"wordcloud.tsv" def save(self, filename=None, limit=None): """ @@ -451,7 +451,7 @@ def add_word(self, word): class DNSMutator(Mutator): """ - DNS-specific mutator used by the `massdns` module to generate target-specific subdomain mutations. + DNS-specific mutator used by the `dnsbrute_mutations` module to generate target-specific subdomain mutations. This class extends the Mutator base class to add DNS-specific logic for generating subdomain mutations based on input words. It utilizes custom word extraction patterns diff --git a/bbot/core/logger/__init__.py b/bbot/core/logger/__init__.py deleted file mode 100644 index 39f447d6a..000000000 --- a/bbot/core/logger/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .logger import ( - init_logging, - get_log_level, - set_log_level, - add_log_handler, - ColoredFormatter, - get_log_handlers, - toggle_log_level, - remove_log_handler, -) diff --git a/bbot/core/logger/logger.py b/bbot/core/logger/logger.py deleted file mode 100644 index eb8da4c55..000000000 --- a/bbot/core/logger/logger.py +++ /dev/null @@ -1,238 +0,0 @@ -import os -import sys -import logging -from copy import copy -import logging.handlers -from pathlib import Path - -from ..configurator import config -from ..helpers.misc import mkdir, error_and_exit -from ..helpers.logger import colorize, loglevel_mapping - - -_log_level_override = None - -bbot_loggers = None -bbot_log_handlers = None - -debug_format = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s %(filename)s:%(lineno)s %(message)s") - - -class ColoredFormatter(logging.Formatter): - """ - Pretty colors for terminal - """ - - formatter = logging.Formatter("%(levelname)s %(message)s") - module_formatter = logging.Formatter("%(levelname)s %(name)s: %(message)s") - - def format(self, record): - colored_record = copy(record) - levelname = colored_record.levelname - levelshort = loglevel_mapping.get(levelname, "INFO") - colored_record.levelname = colorize(f"[{levelshort}]", level=levelname) - if levelname == "CRITICAL" or levelname.startswith("HUGE"): - colored_record.msg = colorize(colored_record.msg, level=levelname) - # remove name - if colored_record.name.startswith("bbot.modules."): - colored_record.name = colored_record.name.split("bbot.modules.")[-1] - return self.module_formatter.format(colored_record) - return self.formatter.format(colored_record) - - -def addLoggingLevel(levelName, levelNum, methodName=None): - """ - Comprehensively adds a new logging level to the `logging` module and the - currently configured logging class. - - `levelName` becomes an attribute of the `logging` module with the value - `levelNum`. `methodName` becomes a convenience method for both `logging` - itself and the class returned by `logging.getLoggerClass()` (usually just - `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is - used. - - To avoid accidental clobberings of existing attributes, this method will - raise an `AttributeError` if the level name is already an attribute of the - `logging` module or if the method name is already present - - Example - ------- - >>> addLoggingLevel('TRACE', logging.DEBUG - 5) - >>> logging.getLogger(__name__).setLevel('TRACE') - >>> logging.getLogger(__name__).trace('that worked') - >>> logging.trace('so did this') - >>> logging.TRACE - 5 - - """ - if not methodName: - methodName = levelName.lower() - - if hasattr(logging, levelName): - raise AttributeError(f"{levelName} already defined in logging module") - if hasattr(logging, methodName): - raise AttributeError(f"{methodName} already defined in logging module") - if hasattr(logging.getLoggerClass(), methodName): - raise AttributeError(f"{methodName} already defined in logger class") - - # This method was inspired by the answers to Stack Overflow post - # http://stackoverflow.com/q/2183233/2988730, especially - # http://stackoverflow.com/a/13638084/2988730 - def logForLevel(self, message, *args, **kwargs): - if self.isEnabledFor(levelNum): - self._log(levelNum, message, args, **kwargs) - - def logToRoot(message, *args, **kwargs): - logging.log(levelNum, message, *args, **kwargs) - - logging.addLevelName(levelNum, levelName) - setattr(logging, levelName, levelNum) - setattr(logging.getLoggerClass(), methodName, logForLevel) - setattr(logging, methodName, logToRoot) - - -# custom logging levels -addLoggingLevel("STDOUT", 100) -addLoggingLevel("TRACE", 49) -addLoggingLevel("HUGEWARNING", 31) -addLoggingLevel("HUGESUCCESS", 26) -addLoggingLevel("SUCCESS", 25) -addLoggingLevel("HUGEINFO", 21) -addLoggingLevel("HUGEVERBOSE", 16) -addLoggingLevel("VERBOSE", 15) - - -verbosity_levels_toggle = [logging.INFO, logging.VERBOSE, logging.DEBUG] - - -def get_bbot_loggers(): - global bbot_loggers - if bbot_loggers is None: - bbot_loggers = [ - logging.getLogger("bbot"), - logging.getLogger("asyncio"), - ] - return bbot_loggers - - -def add_log_handler(handler, formatter=None): - if handler.formatter is None: - handler.setFormatter(debug_format) - for logger in get_bbot_loggers(): - if handler not in logger.handlers: - logger.addHandler(handler) - - -def remove_log_handler(handler): - for logger in get_bbot_loggers(): - if handler in logger.handlers: - logger.removeHandler(handler) - - -def init_logging(): - # Don't do this more than once - if len(logging.getLogger("bbot").handlers) == 0: - for logger in get_bbot_loggers(): - include_logger(logger) - - -def include_logger(logger): - bbot_loggers = get_bbot_loggers() - if logger not in bbot_loggers: - bbot_loggers.append(logger) - logger.setLevel(get_log_level()) - for handler in get_log_handlers().values(): - logger.addHandler(handler) - - -def get_log_handlers(): - global bbot_log_handlers - - if bbot_log_handlers is None: - log_dir = Path(config["home"]) / "logs" - if not mkdir(log_dir, raise_error=False): - error_and_exit(f"Failure creating or error writing to BBOT logs directory ({log_dir})") - - # Main log file - main_handler = logging.handlers.TimedRotatingFileHandler( - f"{log_dir}/bbot.log", when="d", interval=1, backupCount=14 - ) - - # Separate log file for debugging - debug_handler = logging.handlers.TimedRotatingFileHandler( - f"{log_dir}/bbot.debug.log", when="d", interval=1, backupCount=14 - ) - - def stderr_filter(record): - log_level = get_log_level() - if record.levelno == logging.STDOUT or (record.levelno == logging.TRACE and log_level > logging.DEBUG): - return False - if record.levelno < log_level: - return False - return True - - # Log to stderr - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.addFilter(stderr_filter) - # Log to stdout - stdout_handler = logging.StreamHandler(sys.stdout) - stdout_handler.addFilter(lambda x: x.levelno == logging.STDOUT) - # log to files - debug_handler.addFilter( - lambda x: x.levelno == logging.TRACE or (x.levelno < logging.VERBOSE and x.levelno != logging.STDOUT) - ) - main_handler.addFilter( - lambda x: x.levelno not in (logging.STDOUT, logging.TRACE) and x.levelno >= logging.VERBOSE - ) - - # Set log format - debug_handler.setFormatter(debug_format) - main_handler.setFormatter(debug_format) - stderr_handler.setFormatter(ColoredFormatter("%(levelname)s %(name)s: %(message)s")) - stdout_handler.setFormatter(logging.Formatter("%(message)s")) - - bbot_log_handlers = { - "stderr": stderr_handler, - "stdout": stdout_handler, - "file_debug": debug_handler, - "file_main": main_handler, - } - return bbot_log_handlers - - -def get_log_level(): - if _log_level_override is not None: - return _log_level_override - - from bbot.core.configurator.args import cli_options - - if config.get("debug", False) or os.environ.get("BBOT_DEBUG", "").lower() in ("true", "yes"): - return logging.DEBUG - - loglevel = logging.INFO - if cli_options is not None: - if cli_options.verbose: - loglevel = logging.VERBOSE - if cli_options.debug: - loglevel = logging.DEBUG - return loglevel - - -def set_log_level(level, logger=None): - global _log_level_override - if logger is not None: - logger.hugeinfo(f"Setting log level to {logging.getLevelName(level)}") - config["silent"] = False - _log_level_override = level - for logger in bbot_loggers: - logger.setLevel(level) - - -def toggle_log_level(logger=None): - log_level = get_log_level() - if log_level in verbosity_levels_toggle: - for i, level in enumerate(verbosity_levels_toggle): - if log_level == level: - set_log_level(verbosity_levels_toggle[(i + 1) % len(verbosity_levels_toggle)], logger=logger) - else: - set_log_level(verbosity_levels_toggle[0], logger=logger) diff --git a/bbot/core/helpers/modules.py b/bbot/core/modules.py similarity index 55% rename from bbot/core/helpers/modules.py rename to bbot/core/modules.py index c6cc52f42..dd1e43698 100644 --- a/bbot/core/helpers/modules.py +++ b/bbot/core/modules.py @@ -1,29 +1,106 @@ +import re import ast import sys +import atexit +import pickle +import logging import importlib +import omegaconf import traceback +from copy import copy from pathlib import Path from omegaconf import OmegaConf from contextlib import suppress -from ..flags import flag_descriptions -from .misc import list_files, sha1, search_dict_by_key, search_format_dict, make_table, os_platform +from bbot.core import CORE +from bbot.errors import BBOTError +from bbot.logger import log_to_stderr + +from .flags import flag_descriptions +from .shared_deps import SHARED_DEPS +from .helpers.misc import ( + list_files, + sha1, + search_dict_by_key, + search_format_dict, + make_table, + os_platform, + mkdir, +) + + +log = logging.getLogger("bbot.module_loader") + +bbot_code_dir = Path(__file__).parent.parent class ModuleLoader: """ - Main class responsible for loading BBOT modules. + Main class responsible for preloading BBOT modules. This class is in charge of preloading modules to determine their dependencies. Once dependencies are identified, they are installed before the actual module is imported. This ensures that all requisite libraries and components are available for the module to function correctly. """ + default_module_dir = bbot_code_dir / "modules" + + module_dir_regex = re.compile(r"^[a-z][a-z0-9_]*$") + + # if a module consumes these event types, automatically assume these dependencies + default_module_deps = {"HTTP_RESPONSE": "httpx", "URL": "httpx", "SOCIAL": "social"} + def __init__(self): - self._preloaded = {} - self._preloaded_orig = None + self.core = CORE + + self._shared_deps = dict(SHARED_DEPS) + + self.__preloaded = {} self._modules = {} self._configs = {} + self.flag_choices = set() + self.all_module_choices = set() + self.scan_module_choices = set() + self.output_module_choices = set() + self.internal_module_choices = set() + + self._preload_cache = None + + self._module_dirs = set() + self._module_dirs_preloaded = set() + self.add_module_dir(self.default_module_dir) + + # save preload cache before exiting + atexit.register(self.save_preload_cache) + + def copy(self): + module_loader_copy = copy(self) + module_loader_copy.__preloaded = dict(self.__preloaded) + return module_loader_copy + + @property + def preload_cache_file(self): + return self.core.cache_dir / "module_preload_cache" + + @property + def module_dirs(self): + return self._module_dirs + + def add_module_dir(self, module_dir): + module_dir = Path(module_dir).resolve() + if module_dir in self._module_dirs: + log.debug(f'Already added custom module dir "{module_dir}"') + return + if not module_dir.is_dir(): + log.warning(f'Failed to add custom module dir "{module_dir}", please make sure it exists') + return + new_module_dirs = set() + for _module_dir in self.get_recursive_dirs(module_dir): + _module_dir = Path(_module_dir).resolve() + if _module_dir not in self._module_dirs: + self._module_dirs.add(_module_dir) + new_module_dirs.add(_module_dir) + self.preload(module_dirs=new_module_dirs) def file_filter(self, file): file = file.resolve() @@ -31,11 +108,11 @@ def file_filter(self, file): return False return file.suffix.lower() == ".py" and file.stem not in ["base", "__init__"] - def preload(self, module_dir): - """Preloads all modules within a directory. + def preload(self, module_dirs=None): + """Preloads all BBOT modules. - This function recursively iterates through each file in the specified directory - and preloads the BBOT module to gather its meta-information and dependencies. + This function recursively iterates through each file in the module directories + and preloads each BBOT module to gather its meta-information and dependencies. Args: module_dir (str or Path): Directory containing BBOT modules to be preloaded. @@ -52,30 +129,120 @@ def preload(self, module_dir): ... } """ - module_dir = Path(module_dir) - for module_file in list_files(module_dir, filter=self.file_filter): - if module_dir.name == "modules": - namespace = f"bbot.modules" - else: - namespace = f"bbot.modules.{module_dir.name}" - try: - preloaded = self.preload_module(module_file) - module_type = "scan" - if module_dir.name in ("output", "internal"): - module_type = str(module_dir.name) - elif module_dir.name not in ("modules"): - preloaded["flags"] = list(set(preloaded["flags"] + [module_dir.name])) - preloaded["type"] = module_type - preloaded["namespace"] = namespace + new_modules = False + if module_dirs is None: + module_dirs = self.module_dirs + + for module_dir in module_dirs: + if module_dir in self._module_dirs_preloaded: + log.debug(f"Already preloaded modules from {module_dir}") + continue + + log.debug(f"Preloading modules from {module_dir}") + new_modules = True + for module_file in list_files(module_dir, filter=self.file_filter): + module_name = module_file.stem + module_file = module_file.resolve() + + # try to load from cache + module_cache_key = (str(module_file), tuple(module_file.stat())) + preloaded = self.preload_cache.get(module_name, {}) + cache_key = preloaded.get("cache_key", ()) + if preloaded and module_cache_key == cache_key: + log.debug(f"Preloading {module_name} from cache") + else: + log.debug(f"Preloading {module_name} from disk") + if module_dir.name == "modules": + namespace = f"bbot.modules" + else: + namespace = f"bbot.modules.{module_dir.name}" + try: + preloaded = self.preload_module(module_file) + module_type = "scan" + if module_dir.name in ("output", "internal"): + module_type = str(module_dir.name) + elif module_dir.name not in ("modules"): + flags = set(preloaded["flags"] + [module_dir.name]) + preloaded["flags"] = sorted(flags) + + # derive module dependencies from watched event types (only for scan modules) + if module_type == "scan": + for event_type in preloaded["watched_events"]: + if event_type in self.default_module_deps: + deps_modules = set(preloaded.get("deps", {}).get("modules", [])) + deps_modules.add(self.default_module_deps[event_type]) + preloaded["deps"]["modules"] = sorted(deps_modules) + + preloaded["type"] = module_type + preloaded["namespace"] = namespace + preloaded["cache_key"] = module_cache_key + + except Exception: + log_to_stderr(f"Error preloading {module_file}\n\n{traceback.format_exc()}", level="CRITICAL") + log_to_stderr(f"Error in {module_file.name}", level="CRITICAL") + sys.exit(1) + + self.all_module_choices.add(module_name) + module_type = preloaded.get("type", "scan") + if module_type == "scan": + self.scan_module_choices.add(module_name) + elif module_type == "output": + self.output_module_choices.add(module_name) + elif module_type == "internal": + self.internal_module_choices.add(module_name) + + flags = preloaded.get("flags", []) + self.flag_choices.update(set(flags)) + + self.__preloaded[module_name] = preloaded config = OmegaConf.create(preloaded.get("config", {})) - self._configs[module_file.stem] = config - self._preloaded[module_file.stem] = preloaded - except Exception: - print(f"[CRIT] Error preloading {module_file}\n\n{traceback.format_exc()}") - print(f"[CRIT] Error in {module_file.name}") - sys.exit(1) + self._configs[module_name] = config - return self._preloaded + self._module_dirs_preloaded.add(module_dir) + + # update default config with module defaults + module_config = omegaconf.OmegaConf.create( + { + "modules": self.configs(), + } + ) + self.core.merge_default(module_config) + + return new_modules + + @property + def preload_cache(self): + if self._preload_cache is None: + self._preload_cache = {} + if self.preload_cache_file.is_file(): + with suppress(Exception): + with open(self.preload_cache_file, "rb") as f: + self._preload_cache = pickle.load(f) + return self._preload_cache + + @preload_cache.setter + def preload_cache(self, value): + self._preload_cache = value + mkdir(self.preload_cache_file.parent) + with open(self.preload_cache_file, "wb") as f: + pickle.dump(self._preload_cache, f) + + def save_preload_cache(self): + self.preload_cache = self.__preloaded + + @property + def _preloaded(self): + return self.__preloaded + + def get_recursive_dirs(self, *dirs): + dirs = set(Path(d).resolve() for d in dirs) + for d in list(dirs): + if not d.is_dir(): + continue + for p in d.iterdir(): + if p.is_dir() and self.module_dir_regex.match(p.name): + dirs.update(self.get_recursive_dirs(p)) + return dirs def preloaded(self, type=None): preloaded = {} @@ -94,9 +261,8 @@ def configs(self, type=None): return OmegaConf.create(configs) def find_and_replace(self, **kwargs): - if self._preloaded_orig is None: - self._preloaded_orig = dict(self._preloaded) - self._preloaded = search_format_dict(self._preloaded_orig, **kwargs) + self.__preloaded = search_format_dict(self.__preloaded, **kwargs) + self._shared_deps = search_format_dict(self._shared_deps, **kwargs) def check_type(self, module, type): return self._preloaded[module]["type"] == type @@ -136,6 +302,9 @@ def preload_module(self, module_file): "options_desc": {}, "hash": "d5a88dd3866c876b81939c920bf4959716e2a374", "deps": { + "modules": [ + "httpx" + ] "pip": [ "python-Wappalyzer~=0.3.1" ], @@ -147,14 +316,16 @@ def preload_module(self, module_file): "sudo": false } """ - watched_events = [] - produced_events = [] - flags = [] + watched_events = set() + produced_events = set() + flags = set() meta = {} - pip_deps = [] - pip_deps_constraints = [] - shell_deps = [] - apt_deps = [] + deps_modules = set() + deps_pip = [] + deps_pip_constraints = [] + deps_shell = [] + deps_apt = [] + deps_common = [] ansible_tasks = [] python_code = open(module_file).read() # take a hash of the code so we can keep track of when it changes @@ -166,84 +337,109 @@ def preload_module(self, module_file): # look for classes if type(root_element) == ast.ClassDef: for class_attr in root_element.body: + # class attributes that are dictionaries if type(class_attr) == ast.Assign and type(class_attr.value) == ast.Dict: # module options if any([target.id == "options" for target in class_attr.targets]): config.update(ast.literal_eval(class_attr.value)) # module options - if any([target.id == "options_desc" for target in class_attr.targets]): + elif any([target.id == "options_desc" for target in class_attr.targets]): options_desc.update(ast.literal_eval(class_attr.value)) # module metadata - if any([target.id == "meta" for target in class_attr.targets]): + elif any([target.id == "meta" for target in class_attr.targets]): meta = ast.literal_eval(class_attr.value) + # class attributes that are lists if type(class_attr) == ast.Assign and type(class_attr.value) == ast.List: # flags if any([target.id == "flags" for target in class_attr.targets]): for flag in class_attr.value.elts: if type(flag.value) == str: - flags.append(flag.value) + flags.add(flag.value) # watched events - if any([target.id == "watched_events" for target in class_attr.targets]): + elif any([target.id == "watched_events" for target in class_attr.targets]): for event_type in class_attr.value.elts: if type(event_type.value) == str: - watched_events.append(event_type.value) + watched_events.add(event_type.value) # produced events - if any([target.id == "produced_events" for target in class_attr.targets]): + elif any([target.id == "produced_events" for target in class_attr.targets]): for event_type in class_attr.value.elts: if type(event_type.value) == str: - produced_events.append(event_type.value) - # python dependencies - if any([target.id == "deps_pip" for target in class_attr.targets]): - for python_dep in class_attr.value.elts: - if type(python_dep.value) == str: - pip_deps.append(python_dep.value) - - if any([target.id == "deps_pip_constraints" for target in class_attr.targets]): - for python_dep in class_attr.value.elts: - if type(python_dep.value) == str: - pip_deps_constraints.append(python_dep.value) + produced_events.add(event_type.value) + # bbot module dependencies + elif any([target.id == "deps_modules" for target in class_attr.targets]): + for dep_module in class_attr.value.elts: + if type(dep_module.value) == str: + deps_modules.add(dep_module.value) + # python dependencies + elif any([target.id == "deps_pip" for target in class_attr.targets]): + for dep_pip in class_attr.value.elts: + if type(dep_pip.value) == str: + deps_pip.append(dep_pip.value) + elif any([target.id == "deps_pip_constraints" for target in class_attr.targets]): + for dep_pip in class_attr.value.elts: + if type(dep_pip.value) == str: + deps_pip_constraints.append(dep_pip.value) # apt dependencies elif any([target.id == "deps_apt" for target in class_attr.targets]): - for apt_dep in class_attr.value.elts: - if type(apt_dep.value) == str: - apt_deps.append(apt_dep.value) + for dep_apt in class_attr.value.elts: + if type(dep_apt.value) == str: + deps_apt.append(dep_apt.value) # bash dependencies elif any([target.id == "deps_shell" for target in class_attr.targets]): - for shell_dep in class_attr.value.elts: - shell_deps.append(ast.literal_eval(shell_dep)) + for dep_shell in class_attr.value.elts: + deps_shell.append(ast.literal_eval(dep_shell)) # ansible playbook elif any([target.id == "deps_ansible" for target in class_attr.targets]): ansible_tasks = ast.literal_eval(class_attr.value) + # shared/common module dependencies + elif any([target.id == "deps_common" for target in class_attr.targets]): + for dep_common in class_attr.value.elts: + if type(dep_common.value) == str: + deps_common.append(dep_common.value) + for task in ansible_tasks: if not "become" in task: task["become"] = False # don't sudo brew elif os_platform() == "darwin" and ("package" in task and task.get("become", False) == True): task["become"] = False + preloaded_data = { - "watched_events": watched_events, - "produced_events": produced_events, - "flags": flags, + "watched_events": sorted(watched_events), + "produced_events": sorted(produced_events), + "flags": sorted(flags), "meta": meta, "config": config, "options_desc": options_desc, "hash": module_hash, "deps": { - "pip": pip_deps, - "pip_constraints": pip_deps_constraints, - "shell": shell_deps, - "apt": apt_deps, + "modules": sorted(deps_modules), + "pip": deps_pip, + "pip_constraints": deps_pip_constraints, + "shell": deps_shell, + "apt": deps_apt, "ansible": ansible_tasks, + "common": deps_common, }, - "sudo": len(apt_deps) > 0, + "sudo": len(deps_apt) > 0, } - if any(x == True for x in search_dict_by_key("become", ansible_tasks)) or any( - x == True for x in search_dict_by_key("ansible_become", ansible_tasks) - ): - preloaded_data["sudo"] = True + ansible_task_list = list(ansible_tasks) + for dep_common in deps_common: + try: + ansible_task_list.extend(self._shared_deps[dep_common]) + except KeyError: + common_choices = ",".join(self._shared_deps) + raise BBOTError( + f'Error while preloading module "{module_file}": No shared dependency named "{dep_common}" (choices: {common_choices})' + ) + for ansible_task in ansible_task_list: + if any(x == True for x in search_dict_by_key("become", ansible_task)) or any( + x == True for x in search_dict_by_key("ansible_become", ansible_tasks) + ): + preloaded_data["sudo"] = True return preloaded_data def load_modules(self, module_names): @@ -299,7 +495,7 @@ def recommend_dependencies(self, modules): """ resolve_choices = {} # step 1: build a dictionary containing event types and their associated modules - # {"IP_ADDRESS": set("nmap", "ipneighbor", ...)} + # {"IP_ADDRESS": set("masscan", "ipneighbor", ...)} watched = {} produced = {} for modname in modules: @@ -359,7 +555,7 @@ def add_or_create(d, k, *items): except KeyError: d[k] = set(items) - def modules_table(self, modules=None, mod_type=None): + def modules_table(self, modules=None, mod_type=None, include_author=False, include_created_date=False): """Generates a table of module information. Constructs a table to display information such as module name, type, and event details. @@ -372,17 +568,21 @@ def modules_table(self, modules=None, mod_type=None): str: A formatted table string. Examples: - >>> print(modules_table(["nmap"])) + >>> print(modules_table(["portscan"])) +----------+--------+-----------------+------------------------------+-------------------------------+----------------------+-------------------+ | Module | Type | Needs API Key | Description | Flags | Consumed Events | Produced Events | +==========+========+=================+==============================+===============================+======================+===================+ - | nmap | scan | No | Execute port scans with nmap | active, aggressive, portscan, | DNS_NAME, IP_ADDRESS | OPEN_TCP_PORT | + | portscan | scan | No | Execute port scans | active, aggressive, portscan, | DNS_NAME, IP_ADDRESS | OPEN_TCP_PORT | | | | | | web-thorough | | | +----------+--------+-----------------+------------------------------+-------------------------------+----------------------+-------------------+ """ table = [] header = ["Module", "Type", "Needs API Key", "Description", "Flags", "Consumed Events", "Produced Events"] + if include_author: + header.append("Author") + if include_created_date: + header.append("Created Date") maxcolwidths = [20, 10, 5, 30, 30, 20, 20] for module_name, preloaded in self.filter_modules(modules, mod_type): module_type = preloaded["type"] @@ -393,17 +593,22 @@ def modules_table(self, modules=None, mod_type=None): meta = preloaded.get("meta", {}) api_key_required = "Yes" if meta.get("auth_required", False) else "No" description = meta.get("description", "") - table.append( - [ - module_name, - module_type, - api_key_required, - description, - ", ".join(flags), - ", ".join(consumed_events), - ", ".join(produced_events), - ] - ) + row = [ + module_name, + module_type, + api_key_required, + description, + ", ".join(flags), + ", ".join(consumed_events), + ", ".join(produced_events), + ] + if include_author: + author = meta.get("author", "") + row.append(author) + if include_created_date: + created_date = meta.get("created_date", "") + row.append(created_date) + table.append(row) return make_table(table, header, maxcolwidths=maxcolwidths) def modules_options(self, modules=None, mod_type=None): @@ -413,14 +618,10 @@ def modules_options(self, modules=None, mod_type=None): modules_options = {} for module_name, preloaded in self.filter_modules(modules, mod_type): modules_options[module_name] = [] - module_type = preloaded["type"] module_options = preloaded["config"] module_options_desc = preloaded["options_desc"] for k, v in sorted(module_options.items(), key=lambda x: x[0]): - module_key = "modules" - if module_type in ("internal", "output"): - module_key = f"{module_type}_modules" - option_name = f"{module_key}.{module_name}.{k}" + option_name = f"modules.{module_name}.{k}" option_type = type(v).__name__ option_description = module_options_desc[k] modules_options[module_name].append((option_name, option_type, option_description, str(v))) @@ -496,5 +697,35 @@ def filter_modules(self, modules=None, mod_type=None): module_list.sort(key=lambda x: x[-1]["type"], reverse=True) return module_list - -module_loader = ModuleLoader() + def ensure_config_files(self): + files = self.core.files_config + mkdir(files.config_dir) + + comment_notice = ( + "# NOTICE: THESE ENTRIES ARE COMMENTED BY DEFAULT\n" + + "# Please be sure to uncomment when inserting API keys, etc.\n" + ) + + config_obj = OmegaConf.to_object(self.core.default_config) + + # ensure bbot.yml + if not files.config_filename.exists(): + log_to_stderr(f"Creating BBOT config at {files.config_filename}") + no_secrets_config = self.core.no_secrets_config(config_obj) + yaml = OmegaConf.to_yaml(no_secrets_config) + yaml = comment_notice + "\n".join(f"# {line}" for line in yaml.splitlines()) + with open(str(files.config_filename), "w") as f: + f.write(yaml) + + # ensure secrets.yml + if not files.secrets_filename.exists(): + log_to_stderr(f"Creating BBOT secrets at {files.secrets_filename}") + secrets_only_config = self.core.secrets_only_config(config_obj) + yaml = OmegaConf.to_yaml(secrets_only_config) + yaml = comment_notice + "\n".join(f"# {line}" for line in yaml.splitlines()) + with open(str(files.secrets_filename), "w") as f: + f.write(yaml) + files.secrets_filename.chmod(0o600) + + +MODULE_LOADER = ModuleLoader() diff --git a/bbot/core/shared_deps.py b/bbot/core/shared_deps.py new file mode 100644 index 000000000..b8c58e0b1 --- /dev/null +++ b/bbot/core/shared_deps.py @@ -0,0 +1,157 @@ +DEP_FFUF = [ + { + "name": "Download ffuf", + "unarchive": { + "src": "https://github.com/ffuf/ffuf/releases/download/v#{BBOT_DEPS_FFUF_VERSION}/ffuf_#{BBOT_DEPS_FFUF_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH}.tar.gz", + "include": "ffuf", + "dest": "#{BBOT_TOOLS}", + "remote_src": True, + }, + } +] + +DEP_DOCKER = [ + { + "name": "Check if Docker is already installed", + "command": "docker --version", + "register": "docker_installed", + "ignore_errors": True, + }, + { + "name": "Install Docker (Non-Debian)", + "package": {"name": "docker", "state": "present"}, + "become": True, + "when": "ansible_facts['os_family'] != 'Debian' and docker_installed.rc != 0", + }, + { + "name": "Install Docker (Debian)", + "package": { + "name": "docker.io", + "state": "present", + }, + "become": True, + "when": "ansible_facts['os_family'] == 'Debian' and docker_installed.rc != 0", + }, +] + +DEP_MASSDNS = [ + { + "name": "install dev tools", + "package": {"name": ["gcc", "git", "make"], "state": "present"}, + "become": True, + "ignore_errors": True, + }, + { + "name": "Download massdns source code", + "git": { + "repo": "https://github.com/blechschmidt/massdns.git", + "dest": "#{BBOT_TEMP}/massdns", + "single_branch": True, + "version": "master", + }, + }, + { + "name": "Build massdns (Linux)", + "command": {"chdir": "#{BBOT_TEMP}/massdns", "cmd": "make", "creates": "#{BBOT_TEMP}/massdns/bin/massdns"}, + "when": "ansible_facts['system'] == 'Linux'", + }, + { + "name": "Build massdns (non-Linux)", + "command": { + "chdir": "#{BBOT_TEMP}/massdns", + "cmd": "make nolinux", + "creates": "#{BBOT_TEMP}/massdns/bin/massdns", + }, + "when": "ansible_facts['system'] != 'Linux'", + }, + { + "name": "Install massdns", + "copy": {"src": "#{BBOT_TEMP}/massdns/bin/massdns", "dest": "#{BBOT_TOOLS}/", "mode": "u+x,g+x,o+x"}, + }, +] + +DEP_CHROMIUM = [ + { + "name": "Install Chromium (Non-Debian)", + "package": {"name": "chromium", "state": "present"}, + "become": True, + "when": "ansible_facts['os_family'] != 'Debian'", + "ignore_errors": True, + }, + { + "name": "Install Chromium dependencies (Debian)", + "package": { + "name": "libasound2,libatk-bridge2.0-0,libatk1.0-0,libcairo2,libcups2,libdrm2,libgbm1,libnss3,libpango-1.0-0,libxcomposite1,libxdamage1,libxfixes3,libxkbcommon0,libxrandr2", + "state": "present", + }, + "become": True, + "when": "ansible_facts['os_family'] == 'Debian'", + "ignore_errors": True, + }, + { + "name": "Get latest Chromium version (Debian)", + "uri": { + "url": "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2FLAST_CHANGE?alt=media", + "return_content": True, + }, + "register": "chromium_version", + "when": "ansible_facts['os_family'] == 'Debian'", + "ignore_errors": True, + }, + { + "name": "Download Chromium (Debian)", + "unarchive": { + "src": "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{{ chromium_version.content }}%2Fchrome-linux.zip?alt=media", + "remote_src": True, + "dest": "#{BBOT_TOOLS}", + "creates": "#{BBOT_TOOLS}/chrome-linux", + }, + "when": "ansible_facts['os_family'] == 'Debian'", + "ignore_errors": True, + }, +] + +DEP_MASSCAN = [ + { + "name": "install os deps (Debian)", + "package": {"name": ["gcc", "git", "make", "libpcap0.8-dev"], "state": "present"}, + "become": True, + "when": "ansible_facts['os_family'] == 'Debian'", + "ignore_errors": True, + }, + { + "name": "install dev tools (Non-Debian)", + "package": {"name": ["gcc", "git", "make", "libpcap"], "state": "present"}, + "become": True, + "when": "ansible_facts['os_family'] != 'Debian'", + "ignore_errors": True, + }, + { + "name": "Download masscan source code", + "git": { + "repo": "https://github.com/robertdavidgraham/masscan.git", + "dest": "#{BBOT_TEMP}/masscan", + "single_branch": True, + "version": "master", + }, + }, + { + "name": "Build masscan", + "command": { + "chdir": "#{BBOT_TEMP}/masscan", + "cmd": "make -j", + "creates": "#{BBOT_TEMP}/masscan/bin/masscan", + }, + }, + { + "name": "Install masscan", + "copy": {"src": "#{BBOT_TEMP}/masscan/bin/masscan", "dest": "#{BBOT_TOOLS}/", "mode": "u+x,g+x,o+x"}, + }, +] + +# shared module dependencies -- ffuf, massdns, chromium, etc. +SHARED_DEPS = {} +for var, val in list(locals().items()): + if var.startswith("DEP_") and isinstance(val, list): + var = var.split("_", 1)[-1].lower() + SHARED_DEPS[var] = val diff --git a/bbot/defaults.yml b/bbot/defaults.yml index 39620f3fd..762aa7606 100644 --- a/bbot/defaults.yml +++ b/bbot/defaults.yml @@ -2,47 +2,110 @@ # BBOT working directory home: ~/.bbot -# Don't output events that are further than this from the main scope -# 1 == 1 hope away from main scope -# 0 == in scope only -scope_report_distance: 0 -# Generate new DNS_NAME and IP_ADDRESS events through DNS resolution -dns_resolution: true -# Limit the number of BBOT threads -max_threads: 25 -# Rate-limit DNS -dns_queries_per_second: 1000 -# Rate-limit HTTP -web_requests_per_second: 100 +# How many scan results to keep before cleaning up the older ones +keep_scans: 20 # Interval for displaying status messages status_frequency: 15 -# HTTP proxy -http_proxy: -# Web user-agent -user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.2151.97 # Include the raw data of files (i.e. PDFs, web screenshots) as base64 in the event file_blobs: false # Include the raw data of directories (i.e. git repos) as tar.gz base64 in the event folder_blobs: false -### WEB SPIDER ### +### SCOPE ### + +scope: + # Filter by scope distance which events are displayed in the output + # 0 == show only in-scope events (affiliates are always shown) + # 1 == show all events up to distance-1 (1 hop from target) + report_distance: 0 + # How far out from the main scope to search + # Do not change this setting unless you know what you're doing + search_distance: 0 + +### DNS ### + +dns: + # Completely disable DNS resolution (careful if you have IP whitelists/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 + # How many instances of the dns module to run concurrently + threads: 20 + # How many concurrent DNS resolvers to use when brute-forcing + # (under the hood this is passed through directly to massdns -s) + brute_threads: 1000 + # How far away from the main target to explore via DNS resolution (independent of scope.search_distance) + # This is safe to change + search_distance: 1 + # Limit how many DNS records can be followed in a row (stop malicious/runaway DNS records) + runaway_limit: 5 + # DNS query timeout + timeout: 5 + # How many times to retry DNS queries + retries: 1 + # Completely disable BBOT's DNS wildcard detection + wildcard_disable: False + # Disable BBOT's DNS wildcard detection for select domains + wildcard_ignore: [] + # How many sanity checks to make when verifying wildcard DNS + # Increase this value if BBOT's wildcard detection isn't working + wildcard_tests: 10 + # Skip DNS requests for a certain domain and rdtype after encountering this many timeouts or SERVFAILs + # This helps prevent faulty DNS servers from hanging up the scan + abort_threshold: 50 + # Don't show PTR records containing IP addresses + filter_ptrs: true + # Enable/disable debug messages for DNS queries + debug: false + # For performance reasons, always skip these DNS queries + # Microsoft's DNS infrastructure is misconfigured so that certain queries to mail.protection.outlook.com always time out + omit_queries: + - SRV:mail.protection.outlook.com + - CNAME:mail.protection.outlook.com + - TXT:mail.protection.outlook.com + +### WEB ### -# Set the maximum number of HTTP links that can be followed in a row (0 == no spidering allowed) -web_spider_distance: 0 -# Set the maximum directory depth for the web spider -web_spider_depth: 1 -# Set the maximum number of links that can be followed per page -web_spider_links_per_page: 25 +web: + # HTTP proxy + http_proxy: + # Web user-agent + user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.2151.97 + # Set the maximum number of HTTP links that can be followed in a row (0 == no spidering allowed) + spider_distance: 0 + # Set the maximum directory depth for the web spider + spider_depth: 1 + # Set the maximum number of links that can be followed per page + spider_links_per_page: 25 + # HTTP timeout (for Python requests; API calls, etc.) + http_timeout: 10 + # HTTP timeout (for httpx) + httpx_timeout: 5 + # Custom HTTP headers (e.g. cookies, etc.) + # in the format { "Header-Key": "header_value" } + # These are attached to all in-scope HTTP requests + # Note that some modules (e.g. github) may end up sending these to out-of-scope resources + http_headers: {} + # HTTP retries (for Python requests; API calls, etc.) + http_retries: 1 + # HTTP retries (for httpx) + httpx_retries: 1 + # Enable/disable debug messages for web requests/responses + debug: false + # Maximum number of HTTP redirects to follow + http_max_redirects: 5 + # Whether to verify SSL certificates + ssl_verify: false +# Tool dependencies +deps: + ffuf: + version: "2.1.0" ### ADVANCED OPTIONS ### -# How far out from the main scope to search -scope_search_distance: 0 -# How far out from the main scope to resolve DNS names / IPs -scope_dns_search_distance: 1 -# Limit how many DNS records can be followed in a row (stop malicious/runaway DNS records) -dns_resolve_distance: 5 +# Load BBOT modules from these custom paths +module_paths: [] # Infer certain events from others, e.g. IPs from IP ranges, DNS_NAMEs from URLs, etc. speculate: True @@ -50,104 +113,75 @@ speculate: True excavate: True # Summarize activity at the end of a scan aggregate: True +# DNS resolution, wildcard detection, etc. +dnsresolve: True +# Cloud provider tagging +cloudcheck: True + +# How to handle installation of module dependencies +# Choices are: +# - abort_on_failure (default) - if a module dependency fails to install, abort the scan +# - retry_failed - try again to install failed dependencies +# - ignore_failed - run the scan regardless of what happens with dependency installation +# - disable - completely disable BBOT's dependency system (you are responsible for installing tools, pip packages, etc.) +deps_behavior: abort_on_failure + +# Strip querystring from URLs by default +url_querystring_remove: True +# When query string is retained, by default collapse parameter values down to a single value per parameter +url_querystring_collapse: True -# HTTP timeout (for Python requests; API calls, etc.) -http_timeout: 10 -# HTTP timeout (for httpx) -httpx_timeout: 5 -# Custom HTTP headers (e.g. cookies, etc.) -# in the format { "Header-Key": "header_value" } -# These are attached to all in-scope HTTP requests -# Note that some modules (e.g. github) may end up sending these to out-of-scope resources -http_headers: {} -# HTTP retries (for Python requests; API calls, etc.) -http_retries: 1 -# HTTP retries (for httpx) -httpx_retries: 1 -# Enable/disable debug messages for web requests/responses -http_debug: false -# Maximum number of HTTP redirects to follow -http_max_redirects: 5 -# DNS query timeout -dns_timeout: 5 -# How many times to retry DNS queries -dns_retries: 1 -# Disable BBOT's smart DNS wildcard handling for select domains -dns_wildcard_ignore: [] -# How many sanity checks to make when verifying wildcard DNS -# Increase this value if BBOT's wildcard detection isn't working -dns_wildcard_tests: 10 -# Skip DNS requests for a certain domain and rdtype after encountering this many timeouts or SERVFAILs -# This helps prevent faulty DNS servers from hanging up the scan -dns_abort_threshold: 50 -# Don't show PTR records containing IP addresses -dns_filter_ptrs: true -# Enable/disable debug messages for dns queries -dns_debug: false -# Whether to verify SSL certificates -ssl_verify: false -# How many scan results to keep before cleaning up the older ones -keep_scans: 20 # Completely ignore URLs with these extensions url_extension_blacklist: - # images - - png - - jpg - - bmp - - ico - - jpeg - - gif - - svg - - webp - # web/fonts - - css - - woff - - woff2 - - ttf - - eot - - sass - - scss - # audio - - mp3 - - m4a - - wav - - flac - # video - - mp4 - - mkv - - avi - - wmv - - mov - - flv - - webm + # images + - png + - jpg + - bmp + - ico + - jpeg + - gif + - svg + - webp + # web/fonts + - css + - woff + - woff2 + - ttf + - eot + - sass + - scss + # audio + - mp3 + - m4a + - wav + - flac + # video + - mp4 + - mkv + - avi + - wmv + - mov + - flv + - webm # Distribute URLs with these extensions only to httpx (these are omitted from output) url_extension_httpx_only: - - js + - js # Don't output these types of events (they are still distributed to modules) omit_event_types: - - HTTP_RESPONSE - - RAW_TEXT - - URL_UNVERIFIED - - DNS_NAME_UNRESOLVED - - FILESYSTEM - # - IP_ADDRESS -# URL of BBOT server -agent_url: '' -# Agent Bearer authentication token -agent_token: '' + - HTTP_RESPONSE + - RAW_TEXT + - URL_UNVERIFIED + - DNS_NAME_UNRESOLVED + - FILESYSTEM + - WEB_PARAMETER + - RAW_DNS_RECORD + # - IP_ADDRESS # Custom interactsh server settings interactsh_server: null interactsh_token: null interactsh_disable: false -# For performance reasons, always skip these DNS queries -# Microsoft's DNS infrastructure is misconfigured so that certain queries to mail.protection.outlook.com always time out -dns_omit_queries: - - SRV:mail.protection.outlook.com - - CNAME:mail.protection.outlook.com - - TXT:mail.protection.outlook.com - # temporary fix to boost scan performance # TODO: remove this when https://github.com/blacklanternsecurity/bbot/issues/1252 is merged target_dns_regex_disable: false diff --git a/bbot/core/errors.py b/bbot/errors.py similarity index 54% rename from bbot/core/errors.py rename to bbot/errors.py index 5e5f57aeb..53bdee48c 100644 --- a/bbot/core/errors.py +++ b/bbot/errors.py @@ -1,6 +1,3 @@ -from httpx import HTTPError, RequestError # noqa - - class BBOTError(Exception): pass @@ -51,3 +48,43 @@ class DNSWildcardBreak(DNSError): class CurlError(BBOTError): pass + + +class PresetNotFoundError(BBOTError): + pass + + +class EnableModuleError(BBOTError): + pass + + +class EnableFlagError(BBOTError): + pass + + +class BBOTArgumentError(BBOTError): + pass + + +class PresetConditionError(BBOTError): + pass + + +class PresetAbortError(PresetConditionError): + pass + + +class BBOTEngineError(BBOTError): + pass + + +class WebError(BBOTEngineError): + pass + + +class DNSError(BBOTEngineError): + pass + + +class ExcavateError(BBOTError): + pass diff --git a/bbot/core/helpers/logger.py b/bbot/logger.py similarity index 100% rename from bbot/core/helpers/logger.py rename to bbot/logger.py diff --git a/bbot/modules/__init__.py b/bbot/modules/__init__.py index 6062b0170..e69de29bb 100644 --- a/bbot/modules/__init__.py +++ b/bbot/modules/__init__.py @@ -1,14 +0,0 @@ -import re -from pathlib import Path -from bbot.core.helpers.modules import module_loader - -dir_regex = re.compile(r"^[a-z][a-z0-9_]*$") - -parent_dir = Path(__file__).parent.resolve() -module_dirs = set([parent_dir]) -for e in parent_dir.iterdir(): - if e.is_dir() and dir_regex.match(e.name) and not e.name == "modules": - module_dirs.add(e) - -for d in module_dirs: - module_loader.preload(d) diff --git a/bbot/modules/ajaxpro.py b/bbot/modules/ajaxpro.py index dd3abdbf0..dda98ad2b 100644 --- a/bbot/modules/ajaxpro.py +++ b/bbot/modules/ajaxpro.py @@ -1,4 +1,4 @@ -import re +import regex as re from bbot.modules.base import BaseModule @@ -37,12 +37,13 @@ async def handle_event(self, event): }, "FINDING", event, + context="{module} discovered Ajaxpro instance ({event.type}) at {event.data}", ) elif event.type == "HTTP_RESPONSE": resp_body = event.data.get("body", None) if resp_body: - ajaxpro_regex_result = self.ajaxpro_regex.search(resp_body) + ajaxpro_regex_result = await self.helpers.re.search(self.ajaxpro_regex, resp_body) if ajaxpro_regex_result: ajax_pro_path = ajaxpro_regex_result.group(0) await self.emit_event( @@ -53,4 +54,5 @@ async def handle_event(self, event): }, "FINDING", event, + context="{module} discovered Ajaxpro instance ({event.type}) at {event.data}", ) diff --git a/bbot/modules/anubisdb.py b/bbot/modules/anubisdb.py index 88a0a3f26..f95adde0b 100644 --- a/bbot/modules/anubisdb.py +++ b/bbot/modules/anubisdb.py @@ -34,7 +34,7 @@ def abort_if_pre(self, hostname): async def abort_if(self, event): # abort if dns name is unresolved - if not "resolved" in event.tags: + if event.type == "DNS_NAME_UNRESOLVED": return True, "DNS name is unresolved" return await super().abort_if(event) diff --git a/bbot/modules/azure_realm.py b/bbot/modules/azure_realm.py index 7fcbbb5b4..9b09eaf5f 100644 --- a/bbot/modules/azure_realm.py +++ b/bbot/modules/azure_realm.py @@ -4,7 +4,7 @@ class azure_realm(BaseModule): watched_events = ["DNS_NAME"] produced_events = ["URL_UNVERIFIED"] - flags = ["affiliates", "subdomain-enum", "cloud-enum", "web-basic", "web-thorough", "passive", "safe"] + flags = ["affiliates", "subdomain-enum", "cloud-enum", "web-basic", "passive", "safe"] meta = { "description": 'Retrieves the "AuthURL" from login.microsoftonline.com/getuserrealm', "created_date": "2023-07-12", @@ -23,10 +23,13 @@ async def handle_event(self, event): auth_url = await self.getuserrealm(domain) if auth_url: url_event = self.make_event( - auth_url, "URL_UNVERIFIED", source=event, tags=["affiliate", "ms-auth-url"] + auth_url, "URL_UNVERIFIED", parent=event, tags=["affiliate", "ms-auth-url"] ) url_event.source_domain = domain - await self.emit_event(url_event) + await self.emit_event( + url_event, + context="{module} queried login.microsoftonline.com for user realm and found {event.type}: {event.data}", + ) async def getuserrealm(self, domain): url = f"https://login.microsoftonline.com/getuserrealm.srf?login=test@{domain}" diff --git a/bbot/modules/azure_tenant.py b/bbot/modules/azure_tenant.py index c75271756..bd4a9b3dc 100644 --- a/bbot/modules/azure_tenant.py +++ b/bbot/modules/azure_tenant.py @@ -1,4 +1,4 @@ -import re +import regex as re from contextlib import suppress from bbot.modules.base import BaseModule @@ -29,7 +29,7 @@ async def handle_event(self, event): tenant_id = None authorization_endpoint = openid_config.get("authorization_endpoint", "") - matches = self.helpers.regexes.uuid_regex.findall(authorization_endpoint) + matches = await self.helpers.re.findall(self.helpers.regexes.uuid_regex, authorization_endpoint) if matches: tenant_id = matches[0] @@ -38,17 +38,30 @@ async def handle_event(self, event): self.verbose(f'Found {len(domains):,} domains under tenant for "{query}": {", ".join(sorted(domains))}') for domain in domains: if domain != query: - await self.emit_event(domain, "DNS_NAME", source=event, tags=["affiliate", "azure-tenant"]) + await self.emit_event( + domain, + "DNS_NAME", + parent=event, + tags=["affiliate", "azure-tenant"], + context=f'{{module}} queried Outlook autodiscover for "{query}" and found {{event.type}}: {{event.data}}', + ) # tenant names if domain.lower().endswith(".onmicrosoft.com"): tenantname = domain.split(".")[0].lower() if tenantname: tenant_names.add(tenantname) - event_data = {"tenant-names": sorted(tenant_names), "domains": sorted(domains)} + tenant_names = sorted(tenant_names) + event_data = {"tenant-names": tenant_names, "domains": sorted(domains)} + tenant_names_str = ",".join(tenant_names) if tenant_id is not None: event_data["tenant-id"] = tenant_id - await self.emit_event(event_data, "AZURE_TENANT", source=event) + await self.emit_event( + event_data, + "AZURE_TENANT", + parent=event, + context=f'{{module}} queried Outlook autodiscover for "{query}" and found {{event.type}}: {tenant_names_str}', + ) async def query(self, domain): url = f"{self.base_url}/autodiscover/autodiscover.svc" @@ -90,7 +103,7 @@ async def query(self, domain): if status_code not in (200, 421): self.verbose(f'Error retrieving azure_tenant domains for "{domain}" (status code: {status_code})') return set(), dict() - found_domains = list(set(self.d_xml_regex.findall(r.text))) + found_domains = list(set(await self.helpers.re.findall(self.d_xml_regex, r.text))) domains = set() for d in found_domains: diff --git a/bbot/modules/baddns.py b/bbot/modules/baddns.py index b46923129..84462856b 100644 --- a/bbot/modules/baddns.py +++ b/bbot/modules/baddns.py @@ -4,9 +4,6 @@ import asyncio import logging -from bbot.core.logger.logger import include_logger - -include_logger(logging.getLogger("baddns")) class baddns(BaseModule): @@ -18,22 +15,29 @@ class baddns(BaseModule): "created_date": "2024-01-18", "author": "@liquidsec", } - options = {"custom_nameservers": [], "only_high_confidence": False} + options = {"custom_nameservers": [], "only_high_confidence": False, "enable_references": False} options_desc = { "custom_nameservers": "Force BadDNS to use a list of custom nameservers", "only_high_confidence": "Do not emit low-confidence or generic detections", + "enable_references": "Enable the references module (off by default)", } - max_event_handlers = 8 - deps_pip = ["baddns~=1.1.789"] + module_threads = 8 + deps_pip = ["baddns~=1.1.796"] def select_modules(self): + + module_list = ["CNAME", "NS", "MX", "TXT"] + if self.config.get("enable_references", False): + module_list.append("references") + selected_modules = [] for m in get_all_modules(): - if m.name in ["CNAME", "NS", "MX", "references", "TXT"]: + if m.name in module_list: selected_modules.append(m) return selected_modules async def setup(self): + self.preset.core.logger.include_logger(logging.getLogger("baddns")) self.custom_nameservers = self.config.get("custom_nameservers", []) or None if self.custom_nameservers: self.custom_nameservers = self.helpers.chain_lists(self.custom_nameservers) @@ -68,7 +72,11 @@ async def handle_event(self, event): "host": str(event.host), } await self.emit_event( - data, "VULNERABILITY", event, tags=[f"baddns-{module_instance.name.lower()}"] + data, + "VULNERABILITY", + event, + tags=[f"baddns-{module_instance.name.lower()}"], + context=f'{{module}}\'s "{r_dict["module"]}" module found {{event.type}}: {r_dict["description"]}', ) elif r_dict["confidence"] in ["UNLIKELY", "POSSIBLE"] and not self.only_high_confidence: @@ -77,7 +85,11 @@ async def handle_event(self, event): "host": str(event.host), } await self.emit_event( - data, "FINDING", event, tags=[f"baddns-{module_instance.name.lower()}"] + data, + "FINDING", + event, + tags=[f"baddns-{module_instance.name.lower()}"], + context=f'{{module}}\'s "{r_dict["module"]}" module found {{event.type}}: {r_dict["description"]}', ) else: self.warning(f"Got unrecognized confidence level: {r['confidence']}") @@ -86,5 +98,9 @@ async def handle_event(self, event): if found_domains: for found_domain in found_domains: await self.emit_event( - found_domain, "DNS_NAME", event, tags=[f"baddns-{module_instance.name.lower()}"] + found_domain, + "DNS_NAME", + event, + tags=[f"baddns-{module_instance.name.lower()}"], + context=f'{{module}}\'s "{r_dict["module"]}" module found {{event.type}}: {{event.data}}', ) diff --git a/bbot/modules/baddns_zone.py b/bbot/modules/baddns_zone.py index 69f93bbe5..28bddf200 100644 --- a/bbot/modules/baddns_zone.py +++ b/bbot/modules/baddns_zone.py @@ -1,11 +1,6 @@ from baddns.base import get_all_modules from .baddns import baddns as baddns_module -import logging -from bbot.core.logger.logger import include_logger - -include_logger(logging.getLogger("baddns_zone")) - class baddns_zone(baddns_module): watched_events = ["DNS_NAME"] @@ -21,8 +16,8 @@ class baddns_zone(baddns_module): "custom_nameservers": "Force BadDNS to use a list of custom nameservers", "only_high_confidence": "Do not emit low-confidence or generic detections", } - max_event_handlers = 8 - deps_pip = ["baddns~=1.1.789"] + module_threads = 8 + deps_pip = ["baddns~=1.1.796"] def select_modules(self): selected_modules = [] @@ -33,6 +28,6 @@ def select_modules(self): # minimize nsec records feeding back into themselves async def filter_event(self, event): - if "baddns-nsec" in event.tags or "baddns-nsec" in event.source.tags: + if "baddns-nsec" in event.tags or "baddns-nsec" in event.parent.tags: return False return True diff --git a/bbot/modules/badsecrets.py b/bbot/modules/badsecrets.py index 19b0de720..4f82736fa 100644 --- a/bbot/modules/badsecrets.py +++ b/bbot/modules/badsecrets.py @@ -1,23 +1,38 @@ import multiprocessing - +from pathlib import Path from .base import BaseModule - from badsecrets.base import carve_all_modules class badsecrets(BaseModule): watched_events = ["HTTP_RESPONSE"] produced_events = ["FINDING", "VULNERABILITY", "TECHNOLOGY"] - flags = ["active", "safe", "web-basic", "web-thorough"] + flags = ["active", "safe", "web-basic"] meta = { "description": "Library for detecting known or weak secrets across many web frameworks", "created_date": "2022-11-19", "author": "@liquidsec", } + options = {"custom_secrets": None} + options_desc = { + "custom_secrets": "Include custom secrets loaded from a local file", + } deps_pip = ["badsecrets~=0.4.490"] + async def setup(self): + self.custom_secrets = None + custom_secrets = self.config.get("custom_secrets", None) + if custom_secrets: + if Path(custom_secrets).is_file(): + self.custom_secrets = custom_secrets + self.info(f"Successfully loaded secrets file [{custom_secrets}]") + else: + self.warning(f"custom secrets file [{custom_secrets}] is not valid") + return None, "Custom secrets file not valid" + return True + @property - def _max_event_handlers(self): + def _module_threads(self): return max(1, multiprocessing.cpu_count() - 1) async def handle_event(self, event): @@ -37,12 +52,13 @@ async def handle_event(self, event): resp_cookies[c2[0]] = c2[1] if resp_body or resp_cookies: try: - r_list = await self.scan.run_in_executor_mp( + r_list = await self.helpers.run_in_executor_mp( carve_all_modules, body=resp_body, headers=resp_headers, cookies=resp_cookies, url=event.data.get("url", None), + custom_resource=self.custom_secrets, ) except Exception as e: self.warning(f"Error processing {event}: {e}") @@ -56,14 +72,21 @@ async def handle_event(self, event): "url": event.data["url"], "host": str(event.host), } - await self.emit_event(data, "VULNERABILITY", event) + await self.emit_event( + data, + "VULNERABILITY", + event, + context=f'{{module}}\'s "{r["detecting_module"]}" module found known {r["description"]["product"]} secret ({{event.type}}): "{r["secret"]}"', + ) elif r["type"] == "IdentifyOnly": # There is little value to presenting a non-vulnerable asp.net viewstate, as it is not crackable without a Matrioshka brain. Just emit a technology instead. if r["detecting_module"] == "ASPNET_Viewstate": + technology = "microsoft asp.net" await self.emit_event( - {"technology": "microsoft asp.net", "url": event.data["url"], "host": str(event.host)}, + {"technology": technology, "url": event.data["url"], "host": str(event.host)}, "TECHNOLOGY", event, + context=f"{{module}} identified {{event.type}}: {technology}", ) else: data = { @@ -71,4 +94,9 @@ async def handle_event(self, event): "url": event.data["url"], "host": str(event.host), } - await self.emit_event(data, "FINDING", event) + await self.emit_event( + data, + "FINDING", + event, + context=f'{{module}} identified cryptographic product ({{event.type}}): "{r["description"]["product"]}"', + ) diff --git a/bbot/modules/base.py b/bbot/modules/base.py index c0bf6d63b..70d91d551 100644 --- a/bbot/modules/base.py +++ b/bbot/modules/base.py @@ -4,8 +4,8 @@ from sys import exc_info from contextlib import suppress +from ..errors import ValidationError from ..core.helpers.misc import get_size # noqa -from ..core.errors import ValidationError from ..core.helpers.async_helpers import TaskCounter, ShuffleQueue @@ -21,6 +21,8 @@ class BaseModule: flags (List): Flags indicating the type of module (must have at least "safe" or "aggressive" and "passive" or "active"). + deps_modules (List): Other BBOT modules this module depends on. Empty list by default. + deps_pip (List): Python dependencies to install via pip. Empty list by default. deps_apt (List): APT package dependencies to install. Empty list by default. @@ -55,7 +57,7 @@ class BaseModule: options_desc (Dict): Descriptions for options, e.g., {"api_key": "API Key"}. Empty dict by default. - max_event_handlers (int): Maximum concurrent instances of handle_event() or handle_batch(). Default is 1. + module_threads (int): Maximum concurrent instances of handle_event() or handle_batch(). Default is 1. batch_size (int): Size of batches processed by handle_batch(). Default is 1. @@ -83,6 +85,7 @@ class BaseModule: options = {} options_desc = {} + deps_modules = [] deps_pip = [] deps_apt = [] deps_shell = [] @@ -97,17 +100,21 @@ class BaseModule: target_only = False in_scope_only = False - _max_event_handlers = 1 + _module_threads = 1 _batch_size = 1 batch_wait = 10 failed_request_abort_threshold = 5 + default_discovery_context = "{module} discovered {event.type}: {event.data}" + _preserve_graph = False _stats_exclude = False _qsize = 1000 _priority = 3 _name = "base" _type = "scan" + _intercept = False + _shuffle_incoming_queue = True def __init__(self, scan): """Initializes a module instance. @@ -333,11 +340,11 @@ def batch_size(self): return batch_size @property - def max_event_handlers(self): - max_event_handlers = self.config.get("max_event_handlers", None) - if max_event_handlers is None: - max_event_handlers = self._max_event_handlers - return max_event_handlers + def module_threads(self): + module_threads = self.config.get("module_threads", None) + if module_threads is None: + module_threads = self._module_threads + return module_threads @property def auth_secret(self): @@ -391,8 +398,7 @@ async def _handle_batch(self): self.verbose(f"Handling batch of {len(events):,} events") submitted = True async with self.scan._acatch(f"{self.name}.handle_batch()"): - handle_batch_task = asyncio.create_task(self.handle_batch(*events)) - await handle_batch_task + await self.handle_batch(*events) self.verbose(f"Finished handling batch of {len(events):,} events") if finish: context = f"{self.name}.finish()" @@ -411,7 +417,7 @@ def make_event(self, *args, **kwargs): raise_error (bool, optional): Whether to raise a validation error if the event could not be created. Defaults to False. Examples: - >>> new_event = self.make_event("1.2.3.4", source=event) + >>> new_event = self.make_event("1.2.3.4", parent=event) >>> await self.emit_event(new_event) Returns: @@ -421,6 +427,10 @@ def make_event(self, *args, **kwargs): ValidationError: If the event could not be validated and raise_error is True. """ raise_error = kwargs.pop("raise_error", False) + module = kwargs.pop("module", None) + if module is None: + if (not args) or getattr(args[0], "module", None) is None: + kwargs["module"] = self try: event = self.scan.make_event(*args, **kwargs) except ValidationError as e: @@ -428,8 +438,6 @@ def make_event(self, *args, **kwargs): raise self.warning(f"{e}") return - if not event.module: - event.module = self return event async def emit_event(self, *args, **kwargs): @@ -450,9 +458,9 @@ async def emit_event(self, *args, **kwargs): ``` Examples: - >>> await self.emit_event("www.evilcorp.com", source=event, tags=["affiliate"]) + >>> await self.emit_event("www.evilcorp.com", parent=event, tags=["affiliate"]) - >>> new_event = self.make_event("1.2.3.4", source=event) + >>> new_event = self.make_event("1.2.3.4", parent=event) >>> await self.emit_event(new_event) Returns: @@ -470,8 +478,9 @@ async def emit_event(self, *args, **kwargs): event = self.make_event(*args, **event_kwargs) if event: await self.queue_outgoing_event(event, **emit_kwargs) + return event - async def _events_waiting(self): + async def _events_waiting(self, batch_size=None): """ Asynchronously fetches events from the incoming_event_queue, up to a specified batch size. @@ -489,10 +498,12 @@ async def _events_waiting(self): - "FINISHED" events are handled differently and the finish flag is set to True. - If the queue is empty or the batch size is reached, the loop breaks. """ + if batch_size is None: + batch_size = self.batch_size events = [] finish = False while self.incoming_event_queue: - if len(events) > self.batch_size: + if batch_size != -1 and len(events) > self.batch_size: break try: event = self.incoming_event_queue.get_nowait() @@ -519,7 +530,7 @@ def num_incoming_events(self): def start(self): self._tasks = [ - asyncio.create_task(self._worker(), name=f"{self.name}._worker()") for _ in range(self.max_event_handlers) + asyncio.create_task(self._worker(), name=f"{self.name}._worker()") for _ in range(self.module_threads) ] async def _setup(self): @@ -549,8 +560,7 @@ async def _setup(self): status = False self.debug(f"Setting up module {self.name}") try: - setup_task = asyncio.create_task(self.setup()) - result = await setup_task + result = await self.setup() if type(result) == tuple and len(result) == 2: status, msg = result else: @@ -558,17 +568,17 @@ async def _setup(self): msg = status_codes[status] self.debug(f"Finished setting up module {self.name}") except Exception as e: - self.set_error_state() + self.set_error_state(f"Unexpected error during module setup: {e}", critical=True) msg = f"{e}" self.trace() - return self.name, status, str(msg) + return self, status, str(msg) async def _worker(self): """ The core worker loop for the module, responsible for handling events from the incoming event queue. This method is a coroutine and is run asynchronously. Multiple instances can run simultaneously based on - the 'max_event_handlers' configuration. The worker dequeues events from 'incoming_event_queue', performs + the 'module_threads' configuration. The worker dequeues events from 'incoming_event_queue', performs necessary prechecks, and passes the event to the appropriate handler function. Args: @@ -587,7 +597,7 @@ async def _worker(self): - Each event is subject to a post-check via '_event_postcheck()' to decide whether it should be handled. - Special 'FINISHED' events trigger the 'finish()' method of the module. """ - async with self.scan._acatch(context=self._worker): + async with self.scan._acatch(context=self._worker, unhandled_is_critical=True): try: while not self.scan.stopping and not self.errored: # hold the reigns if our outgoing queue is full @@ -617,28 +627,34 @@ async def _worker(self): if event.type == "FINISHED": context = f"{self.name}.finish()" async with self.scan._acatch(context), self._task_counter.count(context): - finish_task = asyncio.create_task(self.finish()) - await finish_task + await self.finish() else: context = f"{self.name}.handle_event({event})" self.scan.stats.event_consumed(event, self) self.debug(f"Handling {event}") async with self.scan._acatch(context), self._task_counter.count(context): - task_name = f"{self.name}.handle_event({event})" - handle_event_task = asyncio.create_task(self.handle_event(event), name=task_name) - await handle_event_task + await self.handle_event(event) self.debug(f"Finished handling {event}") else: self.debug(f"Not accepting {event} because {reason}") except asyncio.CancelledError: - self.log.trace("Worker cancelled") + # 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()) self.log.trace(f"Worker stopped") @property def max_scope_distance(self): if self.in_scope_only or self.target_only: return 0 + if self.scope_distance_modifier is None: + return 999 return max(0, self.scan.scope_search_distance + self.scope_distance_modifier) def _event_precheck(self, event): @@ -681,7 +697,9 @@ def _event_precheck(self, event): if self.target_only: if "target" not in event.tags: return False, "it did not meet target_only filter criteria" + # exclude certain URLs (e.g. javascript): + # TODO: revisit this after httpx rework if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: return False, "its extension was listed in url_extension_httpx_only" @@ -691,16 +709,19 @@ async def _event_postcheck(self, event): """ A simple wrapper for dup tracking """ - acceptable, reason = await self.__event_postcheck(event) + # special exception for "FINISHED" event + if event.type in ("FINISHED",): + return True, "" + acceptable, reason = await self._event_postcheck_inner(event) if acceptable: # check duplicates is_incoming_duplicate, reason = self.is_incoming_duplicate(event, add=True) if is_incoming_duplicate and not self.accept_dupes: - return False, f"module has already seen {event}" + (f" ({reason})" if reason else "") + return False, f"module has already seen it" + (f" ({reason})" if reason else "") return acceptable, reason - async def __event_postcheck(self, event): + async def _event_postcheck_inner(self, event): """ Post-checks an event to determine if it should be accepted by the module for handling. @@ -718,21 +739,10 @@ async def __event_postcheck(self, event): - This method also maintains host-based tracking when the `per_host_only` or similar flags are set. - The method will also update event production stats for output modules. """ - # special exception for "FINISHED" event - if event.type in ("FINISHED",): - return True, "" - # force-output certain events to the graph if self._is_graph_important(event): return True, "event is critical to the graph" - # don't send out-of-scope targets to active modules (excluding portscanners, because they can handle it) - # this only takes effect if your target and whitelist are different - # TODO: the logic here seems incomplete, it could probably use some work. - if "active" in self.flags and "portscan" not in self.flags: - if "target" in event.tags and event not in self.scan.whitelist: - return False, "it is not in whitelist and module has active flag" - # check scope distance filter_result, reason = self._scope_distance_check(event) if not filter_result: @@ -740,7 +750,12 @@ async def __event_postcheck(self, event): # custom filtering async with self.scan._acatch(context=self.filter_event): - filter_result = await self.filter_event(event) + try: + filter_result = await self.filter_event(event) + except Exception as e: + msg = f"Unhandled exception in {self.name}.filter_event({event}): {e}" + self.error(msg) + return False, msg msg = str(self._custom_filter_criteria_msg) with suppress(ValueError, TypeError): filter_result, reason = filter_result @@ -774,7 +789,7 @@ async def _cleanup(self): async with self.scan._acatch(context), self._task_counter.count(context): await self.helpers.execute_sync_or_async(callback) - async def queue_event(self, event, precheck=True): + async def queue_event(self, event): """ Asynchronously queues an incoming event to the module's event queue for further processing. @@ -797,9 +812,7 @@ async def queue_event(self, event, precheck=True): if self.incoming_event_queue is False: self.debug(f"Not in an acceptable state to queue incoming event") return - acceptable, reason = True, "precheck was skipped" - if precheck: - acceptable, reason = self._event_precheck(event) + acceptable, reason = self._event_precheck(event) if not acceptable: if reason and reason != "its type is not in watched_events": self.debug(f"Not queueing {event} because {reason}") @@ -811,7 +824,7 @@ async def queue_event(self, event, precheck=True): async with self._event_received: self._event_received.notify() if event.type != "FINISHED": - self.scan.manager._new_activity = True + self.scan._new_activity = True except AttributeError: self.debug(f"Not in an acceptable state to queue incoming event") @@ -840,7 +853,7 @@ async def queue_outgoing_event(self, event, **kwargs): except AttributeError: self.debug(f"Not in an acceptable state to queue outgoing event") - def set_error_state(self, message=None, clear_outgoing_queue=False): + def set_error_state(self, message=None, clear_outgoing_queue=False, critical=False): """ Puts the module into an errored state where it cannot accept new events. Optionally logs a warning message. @@ -865,7 +878,11 @@ def set_error_state(self, message=None, clear_outgoing_queue=False): log_msg = "Setting error state" if message is not None: log_msg += f": {message}" - self.warning(log_msg) + if critical: + log_fn = self.error + else: + log_fn = self.warning + log_fn(log_msg) self.errored = True # clear incoming queue if self.incoming_event_queue is not False: @@ -886,7 +903,12 @@ def is_incoming_duplicate(self, event, add=False): if event.type in ("FINISHED",): return False, "" reason = "" - event_hash = self._incoming_dedup_hash(event) + try: + event_hash = self._incoming_dedup_hash(event) + except Exception as e: + msg = f"Unhandled exception in {self.name}._incoming_dedup_hash({event}): {e}" + self.error(msg) + return True, msg with suppress(TypeError, ValueError): event_hash, reason = event_hash is_dup = event_hash in self._incoming_dup_tracker @@ -947,7 +969,7 @@ def get_per_hostport_hash(self, event): >>> event = self.make_event("https://example.com:8443") >>> self.get_per_hostport_hash(event) """ - parsed = getattr(event, "parsed", None) + parsed = getattr(event, "parsed_url", None) if parsed is None: to_hash = self.helpers.make_netloc(event.host, event.port) else: @@ -1068,6 +1090,10 @@ async def request_with_fail_count(self, *args, **kwargs): self.set_error_state(f"Setting error state due to {self._request_failures:,} failed HTTP requests") return r + @property + def preset(self): + return self.scan.preset + @property def config(self): """Property that provides easy access to the module's configuration in the scan's config. @@ -1086,7 +1112,10 @@ def config(self): @property def incoming_event_queue(self): if self._incoming_event_queue is None: - self._incoming_event_queue = ShuffleQueue() + if self._shuffle_incoming_queue: + self._incoming_event_queue = ShuffleQueue() + else: + self._incoming_event_queue = asyncio.Queue() return self._incoming_event_queue @property @@ -1121,7 +1150,7 @@ def http_timeout(self): """ Convenience shortcut to `http_timeout` in the config """ - return self.scan.config.get("http_timeout", 10) + return self.scan.web_config.get("http_timeout", 10) @property def log(self): @@ -1164,9 +1193,14 @@ def log_table(self, *args, **kwargs): >>> self.log_table(['Header1', 'Header2'], [['row1col1', 'row1col2'], ['row2col1', 'row2col2']], table_name="my_table") """ table_name = kwargs.pop("table_name", None) + max_log_entries = kwargs.pop("max_log_entries", None) table = self.helpers.make_table(*args, **kwargs) + lines_logged = 0 for line in table.splitlines(): + if max_log_entries is not None and lines_logged > max_log_entries: + break self.info(line) + lines_logged += 1 if table_name is not None: date = self.helpers.make_date() filename = self.scan.home / f"{self.helpers.tagify(table_name)}-table-{date}.txt" @@ -1185,20 +1219,6 @@ def preserve_graph(self): preserve_graph = self._preserve_graph return preserve_graph - def stdout(self, *args, **kwargs): - """Writes log messages directly to standard output. - - This is typically reserved for output modules only, e.g. `human` or `json`. - - Args: - *args: Variable length argument list to be passed to `self.log.stdout`. - **kwargs: Arbitrary keyword arguments to be passed to `self.log.stdout`. - - Examples: - >>> self.stdout("This will be printed to stdout") - """ - self.log.stdout(*args, extra={"scan_id": self.scan.id}, **kwargs) - def debug(self, *args, trace=False, **kwargs): """Logs debug messages and optionally the stack trace of the most recent exception. @@ -1391,3 +1411,126 @@ def critical(self, *args, trace=True, **kwargs): self.log.critical(*args, extra={"scan_id": self.scan.id}, **kwargs) if trace: self.trace() + + +class InterceptModule(BaseModule): + """ + An Intercept Module is a special type of high-priority module that gets early access to events. + + If you want your module to tag or modify an event before it's distributed to the scan, it should + probably be an intercept module. + + Examples of intercept modules include `dns` (for DNS resolution and wildcard detection) + and `cloud` (for detection and tagging of cloud assets). + """ + + accept_dupes = True + suppress_dupes = False + _intercept = True + + async def _worker(self): + async with self.scan._acatch(context=self._worker, unhandled_is_critical=True): + try: + while not self.scan.stopping and not self.errored: + try: + if self.incoming_event_queue is not False: + incoming = await self.get_incoming_event() + try: + event, kwargs = incoming + except ValueError: + event = incoming + kwargs = {} + else: + self.debug(f"Event queue is in bad state") + break + except asyncio.queues.QueueEmpty: + await asyncio.sleep(0.1) + continue + + if event.type == "FINISHED": + context = f"{self.name}.finish()" + async with self.scan._acatch(context), self._task_counter.count(context): + await self.finish() + continue + + self.debug(f"Got {event} from {getattr(event, 'module', 'unknown_module')}") + + acceptable = True + async with self._task_counter.count(f"event_precheck({event})"): + precheck_pass, reason = self._event_precheck(event) + if not precheck_pass: + self.debug(f"Not intercepting {event} because precheck failed ({reason})") + acceptable = False + async with self._task_counter.count(f"event_postcheck({event})"): + postcheck_pass, reason = await self._event_postcheck(event) + if not postcheck_pass: + self.debug(f"Not intercepting {event} because postcheck failed ({reason})") + acceptable = False + + # whether to pass the event on to the rest of the scan + # defaults to true, unless handle_event returns False + forward_event = True + forward_event_reason = "" + + if acceptable: + context = f"{self.name}.handle_event({event, kwargs})" + self.scan.stats.event_consumed(event, self) + self.debug(f"Intercepting {event}") + async with self.scan._acatch(context), self._task_counter.count(context): + forward_event = await self.handle_event(event, kwargs) + with suppress(ValueError, TypeError): + forward_event, forward_event_reason = forward_event + + self.debug(f"Finished intercepting {event}") + + if forward_event is False: + self.debug(f"Not forwarding {event} because {forward_event_reason}") + continue + + 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()) + self.log.trace(f"Worker stopped") + + async def get_incoming_event(self): + """ + Get an event from this module's incoming event queue + """ + return await self.incoming_event_queue.get() + + async def forward_event(self, event, kwargs): + """ + Used for forwarding the event on to the next intercept module + """ + await self.outgoing_event_queue.put((event, kwargs)) + + async def queue_outgoing_event(self, event, **kwargs): + """ + Used by emit_event() to raise new events to the scan + """ + # if this was a normal module, we'd put it in the outgoing queue + # but because it's an intercept module, we need to queue it at the scan's ingress + await self.scan.ingress_module.queue_event(event, kwargs) + + async def queue_event(self, event, kwargs=None): + """ + Put an event in this module's incoming event queue + """ + if kwargs is None: + kwargs = {} + try: + self.incoming_event_queue.put_nowait((event, kwargs)) + except AttributeError: + self.debug(f"Not in an acceptable state to queue incoming event") + + async def _event_postcheck(self, event): + return await self._event_postcheck_inner(event) diff --git a/bbot/modules/bevigil.py b/bbot/modules/bevigil.py index fa5cf1f1b..3926d90b3 100644 --- a/bbot/modules/bevigil.py +++ b/bbot/modules/bevigil.py @@ -34,13 +34,23 @@ async def handle_event(self, event): subdomains = await self.query(query, request_fn=self.request_subdomains, parse_fn=self.parse_subdomains) if subdomains: for subdomain in subdomains: - await self.emit_event(subdomain, "DNS_NAME", source=event) + await self.emit_event( + subdomain, + "DNS_NAME", + parent=event, + context=f'{{module}} queried BeVigil\'s API for "{query}" and discovered {{event.type}}: {{event.data}}', + ) if self.urls: urls = await self.query(query, request_fn=self.request_urls, parse_fn=self.parse_urls) if urls: - for parsed_url in await self.scan.run_in_executor_mp(self.helpers.validators.collapse_urls, urls): - await self.emit_event(parsed_url.geturl(), "URL_UNVERIFIED", source=event) + for parsed_url in await self.helpers.run_in_executor_mp(self.helpers.validators.collapse_urls, urls): + await self.emit_event( + parsed_url.geturl(), + "URL_UNVERIFIED", + parent=event, + context=f'{{module}} queried BeVigil\'s API for "{query}" and discovered {{event.type}}: {{event.data}}', + ) async def request_subdomains(self, query): url = f"{self.base_url}/{self.helpers.quote(query)}/subdomains/" diff --git a/bbot/modules/bucket_amazon.py b/bbot/modules/bucket_amazon.py index c61c218fc..7829606f7 100644 --- a/bbot/modules/bucket_amazon.py +++ b/bbot/modules/bucket_amazon.py @@ -4,7 +4,7 @@ class bucket_amazon(bucket_template): watched_events = ["DNS_NAME", "STORAGE_BUCKET"] produced_events = ["STORAGE_BUCKET", "FINDING"] - flags = ["active", "safe", "cloud-enum", "web-basic", "web-thorough"] + flags = ["active", "safe", "cloud-enum", "web-basic"] meta = { "description": "Check for S3 buckets related to target", "created_date": "2022-11-04", diff --git a/bbot/modules/bucket_azure.py b/bbot/modules/bucket_azure.py index 375a565d5..c89034ccb 100644 --- a/bbot/modules/bucket_azure.py +++ b/bbot/modules/bucket_azure.py @@ -4,7 +4,7 @@ class bucket_azure(bucket_template): watched_events = ["DNS_NAME", "STORAGE_BUCKET"] produced_events = ["STORAGE_BUCKET", "FINDING"] - flags = ["active", "safe", "cloud-enum", "web-basic", "web-thorough"] + flags = ["active", "safe", "cloud-enum", "web-basic"] meta = { "description": "Check for Azure storage blobs related to target", "created_date": "2022-11-04", @@ -21,9 +21,12 @@ class bucket_azure(bucket_template): # Dirbusting is required to know whether a bucket is public supports_open_check = False - async def check_bucket_exists(self, bucket_name, url): + def build_bucket_request(self, bucket_name, base_domain, region): + url = self.build_url(bucket_name, base_domain, region) url = url.strip("/") + f"/{bucket_name}?restype=container" - response = await self.helpers.request(url, retries=0) + return url, {"retries": 0} + + def check_bucket_exists(self, bucket_name, response): status_code = getattr(response, "status_code", 0) existent_bucket = status_code != 0 - return existent_bucket, set(), bucket_name, url + return existent_bucket, set() diff --git a/bbot/modules/bucket_file_enum.py b/bbot/modules/bucket_file_enum.py index e6e492de9..15a429de9 100644 --- a/bbot/modules/bucket_file_enum.py +++ b/bbot/modules/bucket_file_enum.py @@ -46,7 +46,14 @@ async def handle_aws(self, event): bucket_file = url + "/" + key file_extension = self.helpers.get_file_extension(key) if file_extension not in self.scan.url_extension_blacklist: - await self.emit_event(bucket_file, "URL_UNVERIFIED", source=event, tags="filedownload") + extension_upper = file_extension.upper() + await self.emit_event( + bucket_file, + "URL_UNVERIFIED", + parent=event, + tags="filedownload", + context=f"{{module}} enumerate files in bucket and discovered {extension_upper} file at {{event.type}}: {{event.data}}", + ) urls_emitted += 1 if urls_emitted >= self.file_limit: return diff --git a/bbot/modules/bucket_firebase.py b/bbot/modules/bucket_firebase.py index c3117286e..100e4608e 100644 --- a/bbot/modules/bucket_firebase.py +++ b/bbot/modules/bucket_firebase.py @@ -4,7 +4,7 @@ class bucket_firebase(bucket_template): watched_events = ["DNS_NAME", "STORAGE_BUCKET"] produced_events = ["STORAGE_BUCKET", "FINDING"] - flags = ["active", "safe", "cloud-enum", "web-basic", "web-thorough"] + flags = ["active", "safe", "cloud-enum", "web-basic"] meta = { "description": "Check for open Firebase databases related to target", "created_date": "2023-03-20", @@ -25,9 +25,8 @@ def filter_bucket(self, event): return False, "bucket belongs to a different cloud provider" return True, "" - async def check_bucket_exists(self, bucket_name, url): - url = url.strip("/") + "/.json" - return await super().check_bucket_exists(bucket_name, url) + def build_url(self, bucket_name, base_domain, region): + return f"https://{bucket_name}.{base_domain}/.json" async def check_bucket_open(self, bucket_name, url): url = url.strip("/") + "/.json" diff --git a/bbot/modules/bucket_google.py b/bbot/modules/bucket_google.py index 75b41b235..1b87f639e 100644 --- a/bbot/modules/bucket_google.py +++ b/bbot/modules/bucket_google.py @@ -8,7 +8,7 @@ class bucket_google(bucket_template): watched_events = ["DNS_NAME", "STORAGE_BUCKET"] produced_events = ["STORAGE_BUCKET", "FINDING"] - flags = ["active", "safe", "cloud-enum", "web-basic", "web-thorough"] + flags = ["active", "safe", "cloud-enum", "web-basic"] meta = { "description": "Check for Google object storage related to target", "created_date": "2022-11-04", @@ -60,8 +60,7 @@ async def check_bucket_open(self, bucket_name, url): msg = f"Open permissions on storage bucket ({perms_str})" return (msg, set()) - async def check_bucket_exists(self, bucket_name, url): - response = await self.helpers.request(url) + def check_bucket_exists(self, bucket_name, response): status_code = getattr(response, "status_code", 0) existent_bucket = status_code not in (0, 400, 404) - return existent_bucket, set(), bucket_name, url + return existent_bucket, set() diff --git a/bbot/modules/builtwith.py b/bbot/modules/builtwith.py index 7d6197089..e51bb5db8 100644 --- a/bbot/modules/builtwith.py +++ b/bbot/modules/builtwith.py @@ -38,14 +38,25 @@ async def handle_event(self, event): if subdomains: for s in subdomains: if s != event: - await self.emit_event(s, "DNS_NAME", source=event) + await self.emit_event( + s, + "DNS_NAME", + parent=event, + context=f'{{module}} queried the BuiltWith API for "{query}" and found {{event.type}}: {{event.data}}', + ) # redirects if self.config.get("redirects", True): redirects = await self.query(query, parse_fn=self.parse_redirects, request_fn=self.request_redirects) if redirects: for r in redirects: if r != event: - await self.emit_event(r, "DNS_NAME", source=event, tags=["affiliate"]) + await self.emit_event( + r, + "DNS_NAME", + parent=event, + tags=["affiliate"], + context=f'{{module}} queried the BuiltWith redirect API for "{query}" and found redirect to {{event.type}}: {{event.data}}', + ) async def request_domains(self, query): url = f"{self.base_url}/v20/api.json?KEY={self.api_key}&LOOKUP={query}&NOMETA=yes&NOATTR=yes&HIDETEXT=yes&HIDEDL=yes" diff --git a/bbot/modules/bypass403.py b/bbot/modules/bypass403.py index 546f52fe2..4f3b51789 100644 --- a/bbot/modules/bypass403.py +++ b/bbot/modules/bypass403.py @@ -1,5 +1,5 @@ +from bbot.errors import HttpCompareError from bbot.modules.base import BaseModule -from bbot.core.errors import HttpCompareError """ Port of https://github.com/iamj0ker/bypass-403/ and https://portswigger.net/bappstore/444407b96d9c4de0adb7aed89e826122 @@ -146,14 +146,16 @@ async def handle_event(self, event): "url": event.data, }, "FINDING", - source=event, + parent=event, + context=f"{{module}} discovered multiple potential 403 bypasses ({{event.type}}) for {event.data}", ) else: for description in results: await self.emit_event( {"description": description, "host": str(event.host), "url": event.data}, "FINDING", - source=event, + parent=event, + context=f"{{module}} discovered potential 403 bypass ({{event.type}}) for {event.data}", ) # When a WAF-check helper is available in the future, we will convert to HTTP_RESPONSE and check for the WAF string here. @@ -164,10 +166,10 @@ async def filter_event(self, event): def format_signature(self, sig, event): if sig[3] == True: - cleaned_path = event.parsed.path.strip("/") + cleaned_path = event.parsed_url.path.strip("/") else: - cleaned_path = event.parsed.path.lstrip("/") - kwargs = {"scheme": event.parsed.scheme, "netloc": event.parsed.netloc, "path": cleaned_path} + cleaned_path = event.parsed_url.path.lstrip("/") + kwargs = {"scheme": event.parsed_url.scheme, "netloc": event.parsed_url.netloc, "path": cleaned_path} formatted_url = sig[1].format(**kwargs) if sig[2] != None: formatted_headers = {k: v.format(**kwargs) for k, v in sig[2].items()} diff --git a/bbot/modules/code_repository.py b/bbot/modules/code_repository.py index 1a946d13c..ef76954a9 100644 --- a/bbot/modules/code_repository.py +++ b/bbot/modules/code_repository.py @@ -10,7 +10,7 @@ class code_repository(BaseModule): "created_date": "2024-05-15", "author": "@domwhewell-sage", } - flags = ["passive", "safe", "repo-enum"] + flags = ["passive", "safe", "code-enum"] # platform name : (regex, case_sensitive) code_repositories = { @@ -42,11 +42,15 @@ async def handle_event(self, event): url = match.group() if not case_sensitive: url = url.lower() + url = f"https://{url}" repo_event = self.make_event( - {"url": f"https://{url}"}, + {"url": url}, "CODE_REPOSITORY", tags=platform, - source=event, + parent=event, ) repo_event.scope_distance = event.scope_distance - await self.emit_event(repo_event) + await self.emit_event( + repo_event, + context=f"{{module}} detected {platform} {{event.type}} at {url}", + ) diff --git a/bbot/modules/credshed.py b/bbot/modules/credshed.py index eee229e76..3630646a6 100644 --- a/bbot/modules/credshed.py +++ b/bbot/modules/credshed.py @@ -1,9 +1,9 @@ from contextlib import suppress -from bbot.modules.base import BaseModule +from bbot.modules.templates.subdomain_enum import subdomain_enum -class credshed(BaseModule): +class credshed(subdomain_enum): watched_events = ["DNS_NAME"] produced_events = ["PASSWORD", "HASHED_PASSWORD", "USERNAME", "EMAIL_ADDRESS"] flags = ["passive", "safe"] @@ -43,7 +43,7 @@ async def setup(self): return await super().setup() async def handle_event(self, event): - query = event.data + query = self.make_query(event) cs_query = await self.helpers.request( f"{self.base_url}/api/search", method="POST", @@ -77,13 +77,33 @@ async def handle_event(self, event): if src: tags = [f"credshed-source-{src}"] - email_event = self.make_event(email, "EMAIL_ADDRESS", source=event, tags=tags) + email_event = self.make_event(email, "EMAIL_ADDRESS", parent=event, tags=tags) if email_event is not None: - await self.emit_event(email_event) + await self.emit_event( + email_event, context=f'{{module}} searched for "{query}" and found {{event.type}}: {{event.data}}' + ) if user: - await self.emit_event(f"{email}:{user}", "USERNAME", source=email_event, tags=tags) + await self.emit_event( + f"{email}:{user}", + "USERNAME", + parent=email_event, + tags=tags, + context=f"{{module}} found {email} with {{event.type}}: {{event.data}}", + ) if pw: - await self.emit_event(f"{email}:{pw}", "PASSWORD", source=email_event, tags=tags) + await self.emit_event( + f"{email}:{pw}", + "PASSWORD", + parent=email_event, + tags=tags, + context=f"{{module}} found {email} with {{event.type}}: {{event.data}}", + ) for h_pw in hashes: if h_pw: - await self.emit_event(f"{email}:{h_pw}", "HASHED_PASSWORD", source=email_event, tags=tags) + await self.emit_event( + f"{email}:{h_pw}", + "HASHED_PASSWORD", + parent=email_event, + tags=tags, + context=f"{{module}} found {email} with {{event.type}}: {{event.data}}", + ) diff --git a/bbot/modules/deadly/dastardly.py b/bbot/modules/deadly/dastardly.py index 73df81bdf..4476b99ab 100644 --- a/bbot/modules/deadly/dastardly.py +++ b/bbot/modules/deadly/dastardly.py @@ -13,31 +13,11 @@ class dastardly(BaseModule): } deps_pip = ["lxml~=4.9.2"] - deps_ansible = [ - { - "name": "Check if Docker is already installed", - "command": "docker --version", - "register": "docker_installed", - "ignore_errors": True, - }, - { - "name": "Install Docker (Non-Debian)", - "package": {"name": "docker", "state": "present"}, - "become": True, - "when": "ansible_facts['os_family'] != 'Debian' and docker_installed.rc != 0", - }, - { - "name": "Install Docker (Debian)", - "package": { - "name": "docker.io", - "state": "present", - }, - "become": True, - "when": "ansible_facts['os_family'] == 'Debian' and docker_installed.rc != 0", - }, - ] + deps_common = ["docker"] per_hostport_only = True + default_discovery_context = "{module} performed a light web scan against {event.parent.data['url']} and discovered {event.data['description']} at {event.data['url']}" + async def setup(self): await self.run_process("systemctl", "start", "docker", sudo=True) await self.run_process("docker", "pull", "public.ecr.aws/portswigger/dastardly:latest", sudo=True) @@ -53,7 +33,7 @@ async def filter_event(self, event): return True async def handle_event(self, event): - host = event.parsed._replace(path="/").geturl() + host = event.parsed_url._replace(path="/").geturl() self.verbose(f"Running Dastardly scan against {host}") command, output_file = self.construct_command(host) finished_proc = await self.run_process(command, sudo=True) @@ -72,6 +52,7 @@ async def handle_event(self, event): }, "FINDING", event, + context=f"{{module}} executed web scan against {host} and identified {{event.type}}: {failure.instance}", ) else: await self.emit_event( @@ -83,6 +64,7 @@ async def handle_event(self, event): }, "VULNERABILITY", event, + context=f"{{module}} executed web scan against {host} and identified {failure.severity.lower()} {{event.type}}: {failure.instance}", ) def construct_command(self, target): diff --git a/bbot/modules/deadly/ffuf.py b/bbot/modules/deadly/ffuf.py index 614f79bb5..e0e88fbe8 100644 --- a/bbot/modules/deadly/ffuf.py +++ b/bbot/modules/deadly/ffuf.py @@ -16,7 +16,6 @@ class ffuf(BaseModule): "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-small-directories.txt", "lines": 5000, "max_depth": 0, - "version": "2.0.0", "extensions": "", } @@ -24,21 +23,10 @@ class ffuf(BaseModule): "wordlist": "Specify wordlist to use when finding directories", "lines": "take only the first N lines from the wordlist when finding directories", "max_depth": "the maximum directory depth to attempt to solve", - "version": "ffuf version", "extensions": "Optionally include a list of extensions to extend the keyword with (comma separated)", } - deps_ansible = [ - { - "name": "Download ffuf", - "unarchive": { - "src": "https://github.com/ffuf/ffuf/releases/download/v#{BBOT_MODULES_FFUF_VERSION}/ffuf_#{BBOT_MODULES_FFUF_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH}.tar.gz", - "include": "ffuf", - "dest": "#{BBOT_TOOLS}", - "remote_src": True, - }, - } - ] + deps_common = ["ffuf"] banned_characters = [" "] @@ -68,7 +56,7 @@ async def handle_event(self, event): return # only FFUF against a directory - if "." in event.parsed.path.split("/")[-1]: + if "." in event.parsed_url.path.split("/")[-1]: self.debug("Aborting FFUF as period was detected in right-most path segment (likely a file)") return else: @@ -82,7 +70,13 @@ async def handle_event(self, event): filters = await self.baseline_ffuf(fixed_url, exts=exts) async for r in self.execute_ffuf(self.tempfile, fixed_url, exts=exts, filters=filters): - await self.emit_event(r["url"], "URL_UNVERIFIED", source=event, tags=[f"status-{r['status']}"]) + await self.emit_event( + r["url"], + "URL_UNVERIFIED", + parent=event, + tags=[f"status-{r['status']}"], + context=f"{{module}} brute-forced {event.data} and found {{event.type}}: {{event.data}}", + ) async def filter_event(self, event): if "endpoint" in event.tags: @@ -264,7 +258,7 @@ async def execute_ffuf( command.append("-mc") command.append("all") - for hk, hv in self.scan.config.get("http_headers", {}).items(): + for hk, hv in self.scan.custom_http_headers.items(): command += ["-H", f"{hk}: {hv}"] async for found in self.run_process_live(command): diff --git a/bbot/modules/deadly/nuclei.py b/bbot/modules/deadly/nuclei.py index af5a7ac53..9eeae9109 100644 --- a/bbot/modules/deadly/nuclei.py +++ b/bbot/modules/deadly/nuclei.py @@ -144,19 +144,20 @@ async def handle_batch(self, *events): async for severity, template, tags, host, url, name, extracted_results in self.execute_nuclei(nuclei_input): # this is necessary because sometimes nuclei is inconsistent about the data returned in the host field cleaned_host = temp_target.get(host) - source_event = self.correlate_event(events, cleaned_host) + parent_event = self.correlate_event(events, cleaned_host) - if not source_event: + if not parent_event: continue if url == "": - url = str(source_event.data) + url = str(parent_event.data) if severity == "INFO" and "tech" in tags: await self.emit_event( - {"technology": str(name).lower(), "url": url, "host": str(source_event.host)}, + {"technology": str(name).lower(), "url": url, "host": str(parent_event.host)}, "TECHNOLOGY", - source_event, + parent_event, + context=f"{{module}} scanned {url} and identified {{event.type}}: {str(name).lower()}", ) continue @@ -167,30 +168,32 @@ async def handle_batch(self, *events): if severity in ["INFO", "UNKNOWN"]: await self.emit_event( { - "host": str(source_event.host), + "host": str(parent_event.host), "url": url, "description": description_string, }, "FINDING", - source_event, + parent_event, + context=f"{{module}} scanned {url} and identified {{event.type}}: {description_string}", ) else: await self.emit_event( { "severity": severity, - "host": str(source_event.host), + "host": str(parent_event.host), "url": url, "description": description_string, }, "VULNERABILITY", - source_event, + parent_event, + context=f"{{module}} scanned {url} and identified {severity.lower()} {{event.type}}: {description_string}", ) def correlate_event(self, events, host): for event in events: if host in event: return event - self.verbose(f"Failed to correlate nuclei result for {host}. Possible source events:") + self.verbose(f"Failed to correlate nuclei result for {host}. Possible parent events:") for event in events: self.verbose(f" - {event.data}") @@ -213,6 +216,9 @@ async def execute_nuclei(self, nuclei_input): if self.helpers.system_resolvers: command += ["-r", self.helpers.resolver_file] + for hk, hv in self.scan.custom_http_headers.items(): + command += ["-H", f"{hk}: {hv}"] + for cli_option in ("severity", "templates", "iserver", "itoken", "tags", "etags"): option = getattr(self, cli_option) diff --git a/bbot/modules/deadly/vhost.py b/bbot/modules/deadly/vhost.py index 07fc331bb..98991c53d 100644 --- a/bbot/modules/deadly/vhost.py +++ b/bbot/modules/deadly/vhost.py @@ -22,17 +22,7 @@ class vhost(ffuf): "lines": "take only the first N lines from the wordlist when finding directories", } - deps_ansible = [ - { - "name": "Download ffuf", - "unarchive": { - "src": "https://github.com/ffuf/ffuf/releases/download/v#{BBOT_MODULES_FFUF_VERSION}/ffuf_#{BBOT_MODULES_FFUF_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH}.tar.gz", - "include": "ffuf", - "dest": "#{BBOT_TOOLS}", - "remote_src": True, - }, - } - ] + deps_common = ["ffuf"] in_scope_only = True @@ -43,7 +33,7 @@ async def setup(self): async def handle_event(self, event): if not self.helpers.is_ip(event.host) or self.config.get("force_basehost"): - host = f"{event.parsed.scheme}://{event.parsed.netloc}" + host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" if host in self.scanned_hosts.keys(): return else: @@ -54,7 +44,7 @@ async def handle_event(self, event): if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: - basehost = self.helpers.parent_domain(event.parsed.netloc) + basehost = self.helpers.parent_domain(event.parsed_url.netloc) self.debug(f"Using basehost: {basehost}") async for vhost in self.ffuf_vhost(host, f".{basehost}", event): @@ -65,7 +55,7 @@ async def handle_event(self, event): # check existing host for mutations self.verbose("Checking for vhost mutations on main host") async for vhost in self.ffuf_vhost( - host, f".{basehost}", event, wordlist=self.mutations_check(event.parsed.netloc.split(".")[0]) + host, f".{basehost}", event, wordlist=self.mutations_check(event.parsed_url.netloc.split(".")[0]) ): pass @@ -90,11 +80,23 @@ async def ffuf_vhost(self, host, basehost, event, wordlist=None, skip_dns_host=F wordlist, host, exts=[""], suffix=basehost, filters=filters, mode="hostheader" ): found_vhost_b64 = r["input"]["FUZZ"] - vhost_dict = {"host": str(event.host), "url": host, "vhost": base64.b64decode(found_vhost_b64).decode()} - if f"{vhost_dict['vhost']}{basehost}" != event.parsed.netloc: - await self.emit_event(vhost_dict, "VHOST", source=event) + 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 == False: - await self.emit_event(f"{vhost_dict['vhost']}{basehost}", "DNS_NAME", source=event, tags=["vhost"]) + 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"] @@ -112,13 +114,13 @@ async def finish(self): for host, event in self.scanned_hosts.items(): if host not in self.wordcloud_tried_hosts: - event.parsed = urlparse(host) + event.parsed_url = urlparse(host) self.verbose("Checking main host with wordcloud") if self.config.get("force_basehost"): basehost = self.config.get("force_basehost") else: - basehost = self.helpers.parent_domain(event.parsed.netloc) + basehost = self.helpers.parent_domain(event.parsed_url.netloc) async for vhost in self.ffuf_vhost(host, f".{basehost}", event, wordlist=tempfile): pass diff --git a/bbot/modules/dehashed.py b/bbot/modules/dehashed.py index 89c5d6a17..8913ca403 100644 --- a/bbot/modules/dehashed.py +++ b/bbot/modules/dehashed.py @@ -1,11 +1,11 @@ from contextlib import suppress -from bbot.modules.base import BaseModule +from bbot.modules.templates.subdomain_enum import subdomain_enum -class dehashed(BaseModule): +class dehashed(subdomain_enum): watched_events = ["DNS_NAME"] - produced_events = ["PASSWORD", "HASHED_PASSWORD", "USERNAME"] + produced_events = ["PASSWORD", "HASHED_PASSWORD", "USERNAME", "EMAIL_ADDRESS"] flags = ["passive", "safe", "email-enum"] meta = { "description": "Execute queries against dehashed.com for exposed credentials", @@ -34,11 +34,12 @@ async def setup(self): return await super().setup() async def handle_event(self, event): - async for entries in self.query(event): + query = self.make_query(event) + async for entries in self.query(query): for entry in entries: # we have to clean up the email field because dehashed does a poor job of it email_str = entry.get("email", "").replace("\\", "") - found_emails = list(self.helpers.extract_emails(email_str)) + found_emails = list(await self.helpers.re.extract_emails(email_str)) if not found_emails: self.debug(f"Invalid email from dehashed.com: {email_str}") continue @@ -53,18 +54,39 @@ async def handle_event(self, event): if db_name: tags = [f"db-{db_name}"] if email: - email_event = self.make_event(email, "EMAIL_ADDRESS", source=event, tags=tags) + email_event = self.make_event(email, "EMAIL_ADDRESS", parent=event, tags=tags) if email_event is not None: - await self.emit_event(email_event) + await self.emit_event( + email_event, + context=f'{{module}} searched API for "{query}" and found {{event.type}}: {{event.data}}', + ) if user: - await self.emit_event(f"{email}:{user}", "USERNAME", source=email_event, tags=tags) + await self.emit_event( + f"{email}:{user}", + "USERNAME", + parent=email_event, + tags=tags, + context=f"{{module}} found {email} with {{event.type}}: {{event.data}}", + ) if pw: - await self.emit_event(f"{email}:{pw}", "PASSWORD", source=email_event, tags=tags) + await self.emit_event( + f"{email}:{pw}", + "PASSWORD", + parent=email_event, + tags=tags, + context=f"{{module}} found {email} with {{event.type}}: {{event.data}}", + ) if h_pw: - await self.emit_event(f"{email}:{h_pw}", "HASHED_PASSWORD", source=email_event, tags=tags) + await self.emit_event( + f"{email}:{h_pw}", + "HASHED_PASSWORD", + parent=email_event, + tags=tags, + context=f"{{module}} found {email} with {{event.type}}: {{event.data}}", + ) - async def query(self, event): - query = f"domain:{event.data}" + async def query(self, domain): + query = f"domain:{domain}" url = f"{self.base_url}?query={query}&size=10000&page=" + "{page}" page = 0 num_entries = 0 @@ -86,7 +108,7 @@ async def query(self, event): ) elif (page >= 3) and (total > num_entries): self.info( - f"{event.data} has {total:,} results in Dehashed. The API can only process the first 30,000 results. Please check dehashed.com to get the remaining results." + f"{domain} has {total:,} results in Dehashed. The API can only process the first 30,000 results. Please check dehashed.com to get the remaining results." ) agen.aclose() break diff --git a/bbot/modules/dnsbrute.py b/bbot/modules/dnsbrute.py new file mode 100644 index 000000000..76e2d1804 --- /dev/null +++ b/bbot/modules/dnsbrute.py @@ -0,0 +1,59 @@ +from bbot.modules.templates.subdomain_enum import subdomain_enum + + +class dnsbrute(subdomain_enum): + flags = ["subdomain-enum", "passive", "aggressive"] + watched_events = ["DNS_NAME"] + produced_events = ["DNS_NAME"] + meta = { + "description": "Brute-force subdomains with massdns + static wordlist", + "author": "@TheTechromancer", + "created_date": "2024-04-24", + } + options = { + "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", + "max_depth": 5, + } + options_desc = { + "wordlist": "Subdomain wordlist URL", + "max_depth": "How many subdomains deep to brute force, i.e. 5.4.3.2.1.evilcorp.com", + } + deps_common = ["massdns"] + reject_wildcards = "strict" + dedup_strategy = "lowest_parent" + _qsize = 10000 + + async def setup(self): + self.max_depth = max(1, self.config.get("max_depth", 5)) + self.subdomain_file = await self.helpers.wordlist(self.config.get("wordlist")) + self.subdomain_list = set(self.helpers.read_file(self.subdomain_file)) + self.wordlist_size = len(self.subdomain_list) + return await super().setup() + + async def filter_event(self, event): + eligible, reason = await super().filter_event(event) + query = self.make_query(event) + + # limit brute force depth + subdomain_depth = self.helpers.subdomain_depth(query) + 1 + if subdomain_depth > self.max_depth: + eligible = False + reason = f"subdomain depth of *.{query} ({subdomain_depth}) > max_depth ({self.max_depth})" + + # don't brute-force things that look like autogenerated PTRs + if self.helpers.dns.brute.has_excessive_digits(query): + eligible = False + reason = f'"{query}" looks like an autogenerated PTR' + + return eligible, reason + + async def handle_event(self, event): + query = self.make_query(event) + self.info(f"Brute-forcing {self.wordlist_size:,} subdomains for {query} (source: {event.data})") + for hostname in await self.helpers.dns.brute(self, query, self.subdomain_list): + await self.emit_event( + hostname, + "DNS_NAME", + parent=event, + context=f'{{module}} tried {self.wordlist_size:,} subdomains against "{query}" and found {{event.type}}: {{event.data}}', + ) diff --git a/bbot/modules/dnsbrute_mutations.py b/bbot/modules/dnsbrute_mutations.py new file mode 100644 index 000000000..e817f308a --- /dev/null +++ b/bbot/modules/dnsbrute_mutations.py @@ -0,0 +1,141 @@ +from bbot.modules.base import BaseModule + + +class dnsbrute_mutations(BaseModule): + flags = ["subdomain-enum", "passive", "aggressive", "slow"] + watched_events = ["DNS_NAME"] + produced_events = ["DNS_NAME"] + meta = { + "description": "Brute-force subdomains with massdns + target-specific mutations", + "author": "@TheTechromancer", + "created_date": "2024-04-25", + } + options = { + "max_mutations": 100, + } + options_desc = { + "max_mutations": "Maximum number of target-specific mutations to try per subdomain", + } + deps_common = ["massdns"] + _qsize = 10000 + + async def setup(self): + self.found = {} + self.parent_events = self.helpers.make_target() + self.max_mutations = self.config.get("max_mutations", 500) + # 800M bits == 100MB bloom filter == 10M entries before false positives start emerging + self.mutations_tried = self.helpers.bloom_filter(800000000) + self._mutation_run_counter = {} + return True + + async def handle_event(self, event): + # here we don't brute-force, we just add the subdomain to our end-of-scan TODO + self.add_found(event) + + def add_found(self, event): + self.parent_events.add(event) + host = str(event.host) + if self.helpers.is_subdomain(host): + subdomain, domain = host.split(".", 1) + if not self.helpers.dns.brute.has_excessive_digits(subdomain): + try: + self.found[domain].add(subdomain) + except KeyError: + self.found[domain] = {subdomain} + + async def finish(self): + found = sorted(self.found.items(), key=lambda x: len(x[-1]), reverse=True) + # if we have a lot of rounds to make, don't try mutations on less-populated domains + trimmed_found = [] + if found: + avg_subdomains = sum([len(subdomains) for domain, subdomains in found[:50]]) / len(found[:50]) + for i, (domain, subdomains) in enumerate(found): + # accept domains that are in the top 50 or have more than 5 percent of the average number of subdomains + if i < 50 or (len(subdomains) > 1 and len(subdomains) >= (avg_subdomains * 0.05)): + trimmed_found.append((domain, subdomains)) + else: + self.verbose( + f"Skipping mutations on {domain} because it only has {len(subdomains):,} subdomain(s) (avg: {avg_subdomains:,})" + ) + + base_mutations = set() + try: + for i, (domain, subdomains) in enumerate(trimmed_found): + self.verbose(f"{domain} has {len(subdomains):,} subdomains") + # keep looping as long as we're finding things + while 1: + query = domain + + mutations = set(base_mutations) + + def add_mutation(m): + h = f"{m}.{domain}" + if h not in self.mutations_tried: + self.mutations_tried.add(h) + mutations.add(m) + + # try every subdomain everywhere else + for _domain, _subdomains in found: + if _domain == domain: + continue + for s in _subdomains: + first_segment = s.split(".")[0] + # skip stuff with lots of numbers (e.g. PTRs) + if self.helpers.dns.brute.has_excessive_digits(first_segment): + continue + add_mutation(first_segment) + for word in self.helpers.extract_words( + first_segment, word_regexes=self.helpers.word_cloud.dns_mutator.extract_word_regexes + ): + add_mutation(word) + + # numbers + devops mutations + for mutation in self.helpers.word_cloud.mutations( + subdomains, cloud=False, numbers=3, number_padding=1 + ): + for delimiter in ("", ".", "-"): + m = delimiter.join(mutation).lower() + add_mutation(m) + + # special dns mutator + for subdomain in self.helpers.word_cloud.dns_mutator.mutations( + subdomains, max_mutations=self.max_mutations + ): + add_mutation(subdomain) + + if mutations: + self.info(f"Trying {len(mutations):,} mutations against {domain} ({i+1}/{len(trimmed_found)})") + results = await self.helpers.dns.brute(self, query, mutations) + try: + mutation_run = self._mutation_run_counter[domain] + except KeyError: + self._mutation_run_counter[domain] = mutation_run = 1 + self._mutation_run_counter[domain] += 1 + for hostname in results: + parent_event = self.parent_events.get_host(hostname) + if parent_event is None: + self.warning(f"Could not correlate parent event from: {hostname}") + parent_event = self.scan.root_event + mutation_run_ordinal = self.helpers.integer_to_ordinal(mutation_run) + await self.emit_event( + hostname, + "DNS_NAME", + parent=parent_event, + tags=[f"mutation-{mutation_run}"], + abort_if=self.abort_if, + context=f'{{module}} found a mutated subdomain of "{domain}" on its {mutation_run_ordinal} run: {{event.type}}: {{event.data}}', + ) + if results: + continue + break + except AssertionError as e: + self.warning(e) + + def abort_if(self, event): + if not event.scope_distance == 0: + return True, "event is not in scope" + if "wildcard" in event.tags: + return True, "event is a wildcard" + if "unresolved" in event.tags: + return True, "event is unresolved" + return False, "" diff --git a/bbot/modules/dnscaa.py b/bbot/modules/dnscaa.py index 31760ea05..1d18a811a 100644 --- a/bbot/modules/dnscaa.py +++ b/bbot/modules/dnscaa.py @@ -42,7 +42,7 @@ class dnscaa(BaseModule): watched_events = ["DNS_NAME"] produced_events = ["DNS_NAME", "EMAIL_ADDRESS", "URL_UNVERIFIED"] flags = ["subdomain-enum", "email-enum", "passive", "safe"] - meta = {"description": "Check for CAA records"} + meta = {"description": "Check for CAA records", "author": "@colin-stubbs", "created_date": "2024-05-26"} options = { "in_scope_only": True, "dns_names": True, @@ -83,43 +83,37 @@ async def handle_event(self, event): if r: raw_results, errors = r - for rdtype, answers in raw_results: - for answer in answers: - s = answer.to_text().strip().replace('" "', "") - - # validate CAA record vi regex so that we can determine what to do with it. - caa_match = caa_regex.search(s) - - if ( - caa_match - and caa_match.group("flags") - and caa_match.group("property") - and caa_match.group("text") - ): - # it's legit. - if caa_match.group("property").lower() == "iodef": - if self._emails: - for match in email_regex.finditer(caa_match.group("text")): - start, end = match.span() - email = caa_match.group("text")[start:end] + for answer in raw_results: + s = answer.to_text().strip().replace('" "', "") - await self.emit_event(email, "EMAIL_ADDRESS", tags=tags, source=event) + # validate CAA record vi regex so that we can determine what to do with it. + caa_match = caa_regex.search(s) - if self._urls: - for url_regex in url_regexes: - for match in url_regex.finditer(caa_match.group("text")): - start, end = match.span() - url = caa_match.group("text")[start:end].strip('"').strip() + if caa_match and caa_match.group("flags") and caa_match.group("property") and caa_match.group("text"): + # it's legit. + if caa_match.group("property").lower() == "iodef": + if self._emails: + for match in email_regex.finditer(caa_match.group("text")): + start, end = match.span() + email = caa_match.group("text")[start:end] - await self.emit_event(url, "URL_UNVERIFIED", tags=tags, source=event) + await self.emit_event(email, "EMAIL_ADDRESS", tags=tags, parent=event) - elif caa_match.group("property").lower().startswith("issue"): - if self._dns_names: - for match in dns_name_regex.finditer(caa_match.group("text")): + if self._urls: + for url_regex in url_regexes: + for match in url_regex.finditer(caa_match.group("text")): start, end = match.span() - name = caa_match.group("text")[start:end] + url = caa_match.group("text")[start:end].strip('"').strip() + + await self.emit_event(url, "URL_UNVERIFIED", tags=tags, parent=event) + + elif caa_match.group("property").lower().startswith("issue"): + if self._dns_names: + for match in dns_name_regex.finditer(caa_match.group("text")): + start, end = match.span() + name = caa_match.group("text")[start:end] - await self.emit_event(name, "DNS_NAME", tags=tags, source=event) + await self.emit_event(name, "DNS_NAME", tags=tags, parent=event) # EOF diff --git a/bbot/modules/dnscommonsrv.py b/bbot/modules/dnscommonsrv.py index b44c6464f..819e4967b 100644 --- a/bbot/modules/dnscommonsrv.py +++ b/bbot/modules/dnscommonsrv.py @@ -1,4 +1,4 @@ -from bbot.modules.base import BaseModule +from bbot.modules.templates.subdomain_enum import subdomain_enum # the following are the result of a 1-day internet survey to find the top SRV records # the scan resulted in 36,282 SRV records. the count for each one is shown. @@ -147,35 +147,36 @@ "_imap", # 1 "_iax", # 1 ] +num_srvs = len(common_srvs) -class dnscommonsrv(BaseModule): +class dnscommonsrv(subdomain_enum): watched_events = ["DNS_NAME"] produced_events = ["DNS_NAME"] flags = ["subdomain-enum", "passive", "safe"] meta = {"description": "Check for common SRV records", "created_date": "2022-05-15", "author": "@TheTechromancer"} - options = {"top": 50, "max_event_handlers": 10} - options_desc = { - "top": "How many of the top SRV records to check", - "max_event_handlers": "How many instances of the module to run concurrently", - } - _max_event_handlers = 10 + dedup_strategy = "lowest_parent" - def _incoming_dedup_hash(self, event): - # dedupe by parent - parent_domain = self.helpers.parent_domain(event.data) - return hash(parent_domain), "already processed parent domain" + options = {"max_depth": 2} + options_desc = {"max_depth": "The maximum subdomain depth to brute-force SRV records"} + + async def setup(self): + self.max_subdomain_depth = self.config.get("max_depth", 2) + return True async def filter_event(self, event): - # skip SRV wildcards - if "SRV" in await self.helpers.is_wildcard(event.host): - return False + subdomain_depth = self.helpers.subdomain_depth(event.host) + if subdomain_depth > self.max_subdomain_depth: + return False, f"its subdomain depth ({subdomain_depth}) exceeds max_depth={self.max_subdomain_depth}" return True async def handle_event(self, event): - top = int(self.config.get("top", 50)) - parent_domain = self.helpers.parent_domain(event.data) - queries = [f"{srv}.{parent_domain}" for srv in common_srvs[:top]] - async for query, results in self.helpers.resolve_batch(queries, type="srv"): - if results: - await self.emit_event(query, "DNS_NAME", tags=["srv-record"], source=event) + query = self.make_query(event) + self.verbose(f'Brute-forcing {num_srvs:,} SRV records for "{query}"') + for hostname in await self.helpers.dns.brute(self, query, common_srvs, type="SRV"): + await self.emit_event( + hostname, + "DNS_NAME", + parent=event, + context=f'{{module}} tried {num_srvs:,} common SRV records against "{query}" and found {{event.type}}: {{event.data}}', + ) diff --git a/bbot/modules/docker_pull.py b/bbot/modules/docker_pull.py index 47ad31cb3..85f31aae8 100644 --- a/bbot/modules/docker_pull.py +++ b/bbot/modules/docker_pull.py @@ -57,11 +57,13 @@ async def handle_event(self, event): {"path": str(repo_path), "description": f"Docker image repository: {repo_url}"}, "FILESYSTEM", tags=["docker", "tarball"], - source=event, + parent=event, ) if codebase_event: codebase_event.scope_distance = event.scope_distance - await self.emit_event(codebase_event) + await self.emit_event( + codebase_event, context=f"{{module}} downloaded Docker image to {{event.type}}: {repo_path}" + ) def get_registry_and_repository(self, repository_url): """Function to get the registry and repository from a html repository URL.""" diff --git a/bbot/modules/dockerhub.py b/bbot/modules/dockerhub.py index fcfc189c3..c9c206d7a 100644 --- a/bbot/modules/dockerhub.py +++ b/bbot/modules/dockerhub.py @@ -4,7 +4,7 @@ class dockerhub(BaseModule): watched_events = ["SOCIAL", "ORG_STUB"] produced_events = ["SOCIAL", "CODE_REPOSITORY", "URL_UNVERIFIED"] - flags = ["passive", "safe"] + flags = ["passive", "safe", "code-enum"] meta = { "description": "Search for docker repositories of discovered orgs/usernames", "created_date": "2024-03-12", @@ -40,19 +40,26 @@ async def handle_org_stub(self, event): site_url = f"{self.site_url}/u/{p}" # emit social event await self.emit_event( - {"platform": "docker", "url": site_url, "profile_name": p}, "SOCIAL", source=event + {"platform": "docker", "url": site_url, "profile_name": p}, + "SOCIAL", + parent=event, + context=f"{{module}} tried {event.type} {event.data} and found docker profile ({{event.type}}) at {p}", ) async def handle_social(self, event): username = event.data.get("profile_name", "") if not username: return - # emit API endpoint to be visited by httpx (for url/email extraction, etc.) - await self.emit_event(f"{self.api_url}/users/{username}", "URL_UNVERIFIED", source=event, tags="httpx-safe") self.verbose(f"Searching for docker images belonging to {username}") repos = await self.get_repos(username) for repo in repos: - await self.emit_event({"url": repo}, "CODE_REPOSITORY", tags="docker", source=event) + await self.emit_event( + {"url": repo}, + "CODE_REPOSITORY", + tags="docker", + parent=event, + context=f"{{module}} found docker image {{event.type}}: {repo}", + ) async def get_repos(self, username): repos = [] diff --git a/bbot/modules/dotnetnuke.py b/bbot/modules/dotnetnuke.py index 2845a5192..2207600e2 100644 --- a/bbot/modules/dotnetnuke.py +++ b/bbot/modules/dotnetnuke.py @@ -1,5 +1,5 @@ +from bbot.errors import InteractshError from bbot.modules.base import BaseModule -from bbot.core.errors import InteractshError class dotnetnuke(BaseModule): @@ -48,15 +48,18 @@ async def interactsh_callback(self, r): event = self.interactsh_subdomain_tags.get(full_id.split(".")[0]) if not event: return + url = event.data["url"] + description = "DotNetNuke Blind-SSRF (CVE 2017-0929)" await self.emit_event( { "severity": "MEDIUM", "host": str(event.host), - "url": event.data["url"], - "description": f"DotNetNuke Blind-SSRF (CVE 2017-0929)", + "url": url, + "description": description, }, "VULNERABILITY", event, + context=f"{{module}} scanned {url} and found medium {{event.type}}: {description}", ) else: # this is likely caused by something trying to resolve the base domain first and can be ignored @@ -69,10 +72,12 @@ async def handle_event(self, event): if raw_headers: for header_signature in self.DNN_signatures_header: if header_signature in raw_headers: + url = event.data["url"] await self.emit_event( - {"technology": "DotNetNuke", "url": event.data["url"], "host": str(event.host)}, + {"technology": "DotNetNuke", "url": url, "host": str(event.host)}, "TECHNOLOGY", event, + context=f"{{module}} scanned {url} and found {{event.type}}: DotNetNuke", ) detected = True break @@ -84,6 +89,7 @@ async def handle_event(self, event): {"technology": "DotNetNuke", "url": event.data["url"], "host": str(event.host)}, "TECHNOLOGY", event, + context=f"{{module}} scanned {event.data['url']} and found {{event.type}}: DotNetNuke", ) detected = True break @@ -94,15 +100,17 @@ async def handle_event(self, event): result = await self.helpers.request(probe_url, cookies=self.exploit_probe) if result: if "for 16-bit app support" in result.text and "[extensions]" in result.text: + description = "DotNetNuke Personalization Cookie Deserialization" await self.emit_event( { "severity": "CRITICAL", - "description": "DotNetNuke Personalization Cookie Deserialization", + "description": description, "host": str(event.host), "url": probe_url, }, "VULNERABILITY", event, + context=f"{{module}} scanned {probe_url} and found critical {{event.type}}: {description}", ) if "endpoint" not in event.tags: @@ -113,15 +121,17 @@ async def handle_event(self, event): ) if result: if "" in result.text: + description = "DotNetNuke dnnUI_NewsArticlesSlider Module Arbitrary File Read" await self.emit_event( { "severity": "CRITICAL", - "description": "DotNetNuke dnnUI_NewsArticlesSlider Module Arbitrary File Read", + "description": description, "host": str(event.host), "url": f'{event.data["url"]}/DesktopModules/dnnUI_NewsArticlesSlider/ImageHandler.ashx', }, "VULNERABILITY", event, + context=f'{{module}} scanned {event.data["url"]} and found critical {{event.type}}: {description}', ) # DNNArticle GetCSS.ashx File Read @@ -130,15 +140,17 @@ async def handle_event(self, event): ) if result: if "" in result.text: + description = "DotNetNuke DNNArticle Module GetCSS.ashx Arbitrary File Read" await self.emit_event( { "severity": "CRITICAL", - "description": "DotNetNuke DNNArticle Module GetCSS.ashx Arbitrary File Read", + "description": description, "host": str(event.host), "url": f'{event.data["url"]}/Desktopmodules/DNNArticle/GetCSS.ashx/?CP=%2fweb.config', }, "VULNERABILITY", event, + context=f'{{module}} scanned {event.data["url"]} and found critical {{event.type}}: {description}', ) # InstallWizard SuperUser Privilege Escalation @@ -148,15 +160,17 @@ async def handle_event(self, event): f'{event.data["url"]}/Install/InstallWizard.aspx?__viewstate=1' ) if result_confirm.status_code == 500: + description = "DotNetNuke InstallWizard SuperUser Privilege Escalation" await self.emit_event( { "severity": "CRITICAL", - "description": "DotNetNuke InstallWizard SuperUser Privilege Escalation", + "description": description, "host": str(event.host), "url": f'{event.data["url"]}/Install/InstallWizard.aspx', }, "VULNERABILITY", event, + context=f'{{module}} scanned {event.data["url"]} and found critical {{event.type}}: {description}', ) return diff --git a/bbot/modules/emailformat.py b/bbot/modules/emailformat.py index be797c678..c7161070a 100644 --- a/bbot/modules/emailformat.py +++ b/bbot/modules/emailformat.py @@ -21,6 +21,11 @@ async def handle_event(self, event): r = await self.request_with_fail_count(url) if not r: return - for email in self.helpers.extract_emails(r.text): + for email in await self.helpers.re.extract_emails(r.text): if email.endswith(query): - await self.emit_event(email, "EMAIL_ADDRESS", source=event) + await self.emit_event( + email, + "EMAIL_ADDRESS", + parent=event, + context=f'{{module}} searched email-format.com for "{query}" and found {{event.type}}: {{event.data}}', + ) diff --git a/bbot/modules/ffuf_shortnames.py b/bbot/modules/ffuf_shortnames.py index 8981bde6d..76e36de03 100644 --- a/bbot/modules/ffuf_shortnames.py +++ b/bbot/modules/ffuf_shortnames.py @@ -62,17 +62,7 @@ class ffuf_shortnames(ffuf): "find_delimiters": "Attempt to detect common delimiters and make additional ffuf runs against them", } - deps_ansible = [ - { - "name": "Download ffuf", - "unarchive": { - "src": "https://github.com/ffuf/ffuf/releases/download/v#{BBOT_MODULES_FFUF_VERSION}/ffuf_#{BBOT_MODULES_FFUF_VERSION}_#{BBOT_OS_PLATFORM}_#{BBOT_CPU_ARCH}.tar.gz", - "include": "ffuf", - "dest": "#{BBOT_TOOLS}", - "remote_src": True, - }, - } - ] + deps_common = ["ffuf"] in_scope_only = True @@ -95,8 +85,7 @@ async def setup(self): self.extensions = self.helpers.chain_lists(self.config.get("extensions", ""), validate=True) self.debug(f"Using custom extensions: [{','.join(self.extensions)}]") except ValueError as e: - self.warning(f"Error parsing extensions: {e}") - return False + return False, f"Error parsing extensions: {e}" self.ignore_redirects = self.config.get("ignore_redirects") @@ -106,7 +95,7 @@ async def setup(self): def build_extension_list(self, event): used_extensions = [] - extension_hint = event.parsed.path.rsplit(".", 1)[1].lower().strip() + extension_hint = event.parsed_url.path.rsplit(".", 1)[1].lower().strip() if len(extension_hint) == 3: with open(self.wordlist_extensions) as f: for l in f: @@ -126,78 +115,95 @@ def find_delimiter(self, hint): return None async def filter_event(self, event): + if event.parent.type != "URL": + return False, "its parent event is not of type URL" return True async def handle_event(self, event): - if event.source.type == "URL": - filename_hint = re.sub(r"~\d", "", event.parsed.path.rsplit(".", 1)[0].split("/")[-1]).lower() + filename_hint = re.sub(r"~\d", "", event.parsed_url.path.rsplit(".", 1)[0].split("/")[-1]).lower() - host = f"{event.source.parsed.scheme}://{event.source.parsed.netloc}/" - if host not in self.per_host_collection.keys(): - self.per_host_collection[host] = [(filename_hint, event.source.data)] + host = f"{event.parent.parsed_url.scheme}://{event.parent.parsed_url.netloc}/" + if host not in self.per_host_collection.keys(): + self.per_host_collection[host] = [(filename_hint, event.parent.data)] - else: - self.per_host_collection[host].append((filename_hint, event.source.data)) + else: + self.per_host_collection[host].append((filename_hint, event.parent.data)) - self.shortname_to_event[filename_hint] = event + self.shortname_to_event[filename_hint] = event - root_stub = "/".join(event.parsed.path.split("/")[:-1]) - root_url = f"{event.parsed.scheme}://{event.parsed.netloc}{root_stub}/" + root_stub = "/".join(event.parsed_url.path.split("/")[:-1]) + root_url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}{root_stub}/" - if "shortname-file" in event.tags: - used_extensions = self.build_extension_list(event) - - if len(filename_hint) == 6: - tempfile, tempfile_len = self.generate_templist(prefix=filename_hint) - self.verbose( - f"generated temp word list of size [{str(tempfile_len)}] for filename hint: [{filename_hint}]" - ) - - else: - tempfile = self.helpers.tempfile([filename_hint], pipe=False) - tempfile_len = 1 - - if tempfile_len > 0: - if "shortname-file" in event.tags: - for ext in used_extensions: - async for r in self.execute_ffuf(tempfile, root_url, suffix=f".{ext}"): - await self.emit_event( - r["url"], "URL_UNVERIFIED", source=event, tags=[f"status-{r['status']}"] - ) + if "shortname-file" in event.tags: + used_extensions = self.build_extension_list(event) - elif "shortname-directory" in event.tags: - async for r in self.execute_ffuf(tempfile, root_url, exts=["/"]): - r_url = f"{r['url'].rstrip('/')}/" - await self.emit_event(r_url, "URL_UNVERIFIED", source=event, tags=[f"status-{r['status']}"]) + if len(filename_hint) == 6: + tempfile, tempfile_len = self.generate_templist(prefix=filename_hint) + self.verbose( + f"generated temp word list of size [{str(tempfile_len)}] for filename hint: [{filename_hint}]" + ) - if self.config.get("find_delimiters"): - if "shortname-directory" in event.tags: + else: + tempfile = self.helpers.tempfile([filename_hint], pipe=False) + tempfile_len = 1 + + if tempfile_len > 0: + if "shortname-file" in event.tags: + for ext in used_extensions: + async for r in self.execute_ffuf(tempfile, root_url, suffix=f".{ext}"): + await self.emit_event( + r["url"], + "URL_UNVERIFIED", + parent=event, + tags=[f"status-{r['status']}"], + context=f"{{module}} brute-forced {ext.upper()} files at {root_url} and found {{event.type}}: {{event.data}}", + ) + + elif "shortname-directory" in event.tags: + async for r in self.execute_ffuf(tempfile, root_url, exts=["/"]): + r_url = f"{r['url'].rstrip('/')}/" + await self.emit_event( + r_url, + "URL_UNVERIFIED", + parent=event, + tags=[f"status-{r['status']}"], + context=f"{{module}} brute-forced directories at {r_url} and found {{event.type}}: {{event.data}}", + ) + + if self.config.get("find_delimiters"): + if "shortname-directory" in event.tags: + delimiter_r = self.find_delimiter(filename_hint) + if delimiter_r: + delimiter, prefix, partial_hint = delimiter_r + self.verbose(f"Detected delimiter [{delimiter}] in hint [{filename_hint}]") + tempfile, tempfile_len = self.generate_templist(prefix=partial_hint) + ffuf_prefix = f"{prefix}{delimiter}" + async for r in self.execute_ffuf(tempfile, root_url, prefix=ffuf_prefix, exts=["/"]): + await self.emit_event( + r["url"], + "URL_UNVERIFIED", + parent=event, + tags=[f"status-{r['status']}"], + context=f'{{module}} brute-forced directories with detected prefix "{ffuf_prefix}" and found {{event.type}}: {{event.data}}', + ) + + elif "shortname-file" in event.tags: + for ext in used_extensions: delimiter_r = self.find_delimiter(filename_hint) if delimiter_r: delimiter, prefix, partial_hint = delimiter_r self.verbose(f"Detected delimiter [{delimiter}] in hint [{filename_hint}]") tempfile, tempfile_len = self.generate_templist(prefix=partial_hint) - async for r in self.execute_ffuf( - tempfile, root_url, prefix=f"{prefix}{delimiter}", exts=["/"] - ): + ffuf_prefix = f"{prefix}{delimiter}" + async for r in self.execute_ffuf(tempfile, root_url, prefix=ffuf_prefix, suffix=f".{ext}"): await self.emit_event( - r["url"], "URL_UNVERIFIED", source=event, tags=[f"status-{r['status']}"] + r["url"], + "URL_UNVERIFIED", + parent=event, + tags=[f"status-{r['status']}"], + context=f'{{module}} brute-forced {ext.upper()} files with detected prefix "{ffuf_prefix}" and found {{event.type}}: {{event.data}}', ) - elif "shortname-file" in event.tags: - for ext in used_extensions: - delimiter_r = self.find_delimiter(filename_hint) - if delimiter_r: - delimiter, prefix, partial_hint = delimiter_r - self.verbose(f"Detected delimiter [{delimiter}] in hint [{filename_hint}]") - tempfile, tempfile_len = self.generate_templist(prefix=partial_hint) - async for r in self.execute_ffuf( - tempfile, root_url, prefix=f"{prefix}{delimiter}", suffix=f".{ext}" - ): - await self.emit_event( - r["url"], "URL_UNVERIFIED", source=event, tags=[f"status-{r['status']}"] - ) - async def finish(self): if self.config.get("find_common_prefixes"): per_host_collection = dict(self.per_host_collection) @@ -227,8 +233,9 @@ async def finish(self): await self.emit_event( r["url"], "URL_UNVERIFIED", - source=self.shortname_to_event[hint], + parent=self.shortname_to_event[hint], tags=[f"status-{r['status']}"], + context=f'{{module}} brute-forced directories with common prefix "{prefix}" and found {{event.type}}: {{event.data}}', ) elif "shortname-file" in self.shortname_to_event[hint].tags: used_extensions = self.build_extension_list(self.shortname_to_event[hint]) @@ -243,6 +250,7 @@ async def finish(self): await self.emit_event( r["url"], "URL_UNVERIFIED", - source=self.shortname_to_event[hint], + parent=self.shortname_to_event[hint], tags=[f"status-{r['status']}"], + context=f'{{module}} brute-forced {ext.upper()} files with common prefix "{prefix}" and found {{event.type}}: {{event.data}}', ) diff --git a/bbot/modules/filedownload.py b/bbot/modules/filedownload.py index 0ac30b810..37092be06 100644 --- a/bbot/modules/filedownload.py +++ b/bbot/modules/filedownload.py @@ -14,7 +14,7 @@ class filedownload(BaseModule): watched_events = ["URL_UNVERIFIED", "HTTP_RESPONSE"] produced_events = ["FILESYSTEM"] - flags = ["active", "safe", "web-basic", "web-thorough"] + flags = ["active", "safe", "web-basic"] meta = { "description": "Download common filetypes such as PDF, DOCX, PPTX, etc.", "created_date": "2023-10-11", @@ -142,7 +142,7 @@ async def download_file(self, url, content_type=None, source_event=None): self.files_downloaded += 1 if source_event: file_event = self.make_event( - {"path": str(file_destination)}, "FILESYSTEM", tags=["filedownload", "file"], source=source_event + {"path": str(file_destination)}, "FILESYSTEM", tags=["filedownload", "file"], parent=source_event ) file_event.scope_distance = source_event.scope_distance await self.emit_event(file_event) diff --git a/bbot/modules/fingerprintx.py b/bbot/modules/fingerprintx.py index cc24cac7b..33a317c46 100644 --- a/bbot/modules/fingerprintx.py +++ b/bbot/modules/fingerprintx.py @@ -15,7 +15,7 @@ class fingerprintx(BaseModule): options = {"version": "1.1.4"} options_desc = {"version": "fingerprintx version"} _batch_size = 10 - _max_event_handlers = 2 + _module_threads = 2 _priority = 2 options = {"skip_common_web": True} @@ -74,18 +74,24 @@ async def handle_batch(self, *events): ip = j.get("ip", "") host = j.get("host", ip) port = str(j.get("port", "")) + protocol = j.get("protocol", "").upper() + if not host and port and protocol: + continue banner = j.get("metadata", {}).get("banner", "").strip() - if port: - port_data = f"{host}:{port}" - protocol = j.get("protocol", "") + port_data = f"{host}:{port}" tags = set() if host and ip: tags.add(f"ip-{ip}") - if host and port and protocol: - source_event = _input.get(port_data) - protocol_data = {"host": host, "protocol": protocol.upper()} - if port: - protocol_data["port"] = port - if banner: - protocol_data["banner"] = banner - await self.emit_event(protocol_data, "PROTOCOL", source=source_event, tags=tags) + parent_event = _input.get(port_data) + protocol_data = {"host": host, "protocol": protocol} + if port: + protocol_data["port"] = port + if banner: + protocol_data["banner"] = banner + await self.emit_event( + protocol_data, + "PROTOCOL", + parent=parent_event, + tags=tags, + context=f"{{module}} probed {port_data} and detected {{event.type}}: {protocol}", + ) diff --git a/bbot/modules/generic_ssrf.py b/bbot/modules/generic_ssrf.py index 5db762b8c..0aa61a3e5 100644 --- a/bbot/modules/generic_ssrf.py +++ b/bbot/modules/generic_ssrf.py @@ -1,5 +1,5 @@ +from bbot.errors import InteractshError from bbot.modules.base import BaseModule -from bbot.core.errors import InteractshError ssrf_params = [ @@ -44,7 +44,7 @@ def __init__(self, parent_module): self.test_paths = self.create_paths() def set_base_url(self, event): - return f"{event.parsed.scheme}://{event.parsed.netloc}" + return f"{event.parsed_url.scheme}://{event.parsed_url.netloc}" def create_paths(self): return self.paths @@ -140,7 +140,7 @@ async def test(self, event): ]> &{rand_entity};""" - test_url = f"{event.parsed.scheme}://{event.parsed.netloc}/" + test_url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/" r = await self.parent_module.helpers.curl( url=test_url, method="POST", raw_body=post_body, headers={"Content-type": "application/xml"} ) @@ -209,6 +209,7 @@ async def interactsh_callback(self, r): }, "VULNERABILITY", 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 diff --git a/bbot/modules/git.py b/bbot/modules/git.py index 1fb3235ee..9a180bc11 100644 --- a/bbot/modules/git.py +++ b/bbot/modules/git.py @@ -6,7 +6,7 @@ class git(BaseModule): watched_events = ["URL"] produced_events = ["FINDING"] - flags = ["active", "safe", "web-basic", "web-thorough"] + flags = ["active", "safe", "web-basic", "code-enum"] meta = { "description": "Check for exposed .git repositories", "created_date": "2023-05-30", @@ -24,19 +24,16 @@ async def handle_event(self, event): self.helpers.urljoin(base_url, ".git/config"), self.helpers.urljoin(f"{base_url}/", ".git/config"), } - tasks = [self.get_url(u) for u in urls] - async for task in self.helpers.as_completed(tasks): - result, url = await task - text = getattr(result, "text", "") + async for url, response in self.helpers.request_batch(urls): + text = getattr(response, "text", "") if not text: text = "" if text: - if getattr(result, "status_code", 0) == 200 and "[core]" in text and not self.fp_regex.match(text): + 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": f"Exposed .git config at {url}"}, + {"host": str(event.host), "url": url, "description": description}, "FINDING", event, + context="{module} detected {event.type}: {description}", ) - - async def get_url(self, url): - return (await self.helpers.request(url), url) diff --git a/bbot/modules/git_clone.py b/bbot/modules/git_clone.py index 113bcddab..3961ea920 100644 --- a/bbot/modules/git_clone.py +++ b/bbot/modules/git_clone.py @@ -39,9 +39,12 @@ async def handle_event(self, event): repo_path = await self.clone_git_repository(repo_url) if repo_path: self.verbose(f"Cloned {repo_url} to {repo_path}") - codebase_event = self.make_event({"path": str(repo_path)}, "FILESYSTEM", tags=["git"], source=event) + codebase_event = self.make_event({"path": str(repo_path)}, "FILESYSTEM", tags=["git"], parent=event) codebase_event.scope_distance = event.scope_distance - await self.emit_event(codebase_event) + await self.emit_event( + codebase_event, + context=f"{{module}} downloaded git repo at {repo_url} to {{event.type}}: {repo_path}", + ) async def clone_git_repository(self, repository_url): if self.api_key: diff --git a/bbot/modules/github_codesearch.py b/bbot/modules/github_codesearch.py index 146cf42e5..ddafb025f 100644 --- a/bbot/modules/github_codesearch.py +++ b/bbot/modules/github_codesearch.py @@ -1,10 +1,11 @@ from bbot.modules.templates.github import github +from bbot.modules.templates.subdomain_enum import subdomain_enum -class github_codesearch(github): +class github_codesearch(github, subdomain_enum): watched_events = ["DNS_NAME"] produced_events = ["CODE_REPOSITORY", "URL_UNVERIFIED"] - flags = ["passive", "subdomain-enum", "safe"] + flags = ["passive", "subdomain-enum", "safe", "code-enum"] meta = { "description": "Query Github's API for code containing the target domain name", "created_date": "2023-12-14", @@ -23,16 +24,21 @@ async def setup(self): async def handle_event(self, event): query = self.make_query(event) for repo_url, raw_urls in (await self.query(query)).items(): - repo_event = self.make_event({"url": repo_url}, "CODE_REPOSITORY", tags="git", source=event) + repo_event = self.make_event({"url": repo_url}, "CODE_REPOSITORY", tags="git", parent=event) if repo_event is None: continue - await self.emit_event(repo_event) + await self.emit_event( + repo_event, + context=f'{{module}} searched github.com for "{query}" and found {{event.type}} with matching content at {repo_url}', + ) for raw_url in raw_urls: - url_event = self.make_event(raw_url, "URL_UNVERIFIED", source=repo_event, tags=["httpx-safe"]) + url_event = self.make_event(raw_url, "URL_UNVERIFIED", parent=repo_event, tags=["httpx-safe"]) if not url_event: continue url_event.scope_distance = repo_event.scope_distance - await self.emit_event(url_event) + await self.emit_event( + url_event, context=f'file matching query "{query}" is at {{event.type}}: {raw_url}' + ) async def query(self, query): repos = {} diff --git a/bbot/modules/github_org.py b/bbot/modules/github_org.py index ef6ffd6d3..1d115b925 100644 --- a/bbot/modules/github_org.py +++ b/bbot/modules/github_org.py @@ -4,7 +4,7 @@ class github_org(github): watched_events = ["ORG_STUB", "SOCIAL"] produced_events = ["CODE_REPOSITORY"] - flags = ["passive", "subdomain-enum", "safe"] + flags = ["passive", "subdomain-enum", "safe", "code-enum"] meta = { "description": "Query Github's API for organization and member repositories", "created_date": "2023-12-14", @@ -59,21 +59,28 @@ async def handle_event(self, event): self.verbose(f"Searching for repos belonging to user {user}") repos = await self.query_user_repos(user) for repo_url in repos: - repo_event = self.make_event({"url": repo_url}, "CODE_REPOSITORY", tags="git", source=event) + repo_event = self.make_event({"url": repo_url}, "CODE_REPOSITORY", tags="git", parent=event) if not repo_event: continue repo_event.scope_distance = event.scope_distance - await self.emit_event(repo_event) + await self.emit_event( + repo_event, + context=f"{{module}} listed repos for GitHub profile and discovered {{event.type}}: {repo_url}", + ) # find members from org (SOCIAL --> SOCIAL) if is_org and self.include_members: self.verbose(f"Searching for any members belonging to {user}") org_members = await self.query_org_members(user) for member in org_members: - event_data = {"platform": "github", "profile_name": member, "url": f"https://github.com/{member}"} - member_event = self.make_event(event_data, "SOCIAL", tags="github-org-member", source=event) + member_url = f"https://github.com/{member}" + event_data = {"platform": "github", "profile_name": member, "url": member_url} + member_event = self.make_event(event_data, "SOCIAL", tags="github-org-member", parent=event) if member_event: - await self.emit_event(member_event) + await self.emit_event( + member_event, + context=f"{{module}} listed members of GitHub organization and discovered {{event.type}}: {member_url}", + ) # find valid orgs from stub (ORG_STUB --> SOCIAL) elif event.type == "ORG_STUB": @@ -86,11 +93,15 @@ async def handle_event(self, event): self.verbose(f"Unable to validate that {user} is in-scope, skipping...") return - event_data = {"platform": "github", "profile_name": user, "url": f"https://github.com/{user}"} - github_org_event = self.make_event(event_data, "SOCIAL", tags="github-org", source=event) + user_url = f"https://github.com/{user}" + event_data = {"platform": "github", "profile_name": user, "url": user_url} + github_org_event = self.make_event(event_data, "SOCIAL", tags="github-org", parent=event) if github_org_event: github_org_event.scope_distance = event.scope_distance - await self.emit_event(github_org_event) + await self.emit_event( + github_org_event, + context=f'{{module}} tried "{user}" as GitHub profile and discovered {{event.type}}: {user_url}', + ) async def query_org_repos(self, query): repos = [] diff --git a/bbot/modules/github_workflows.py b/bbot/modules/github_workflows.py index bf8f7eb6d..76ed2d5ff 100644 --- a/bbot/modules/github_workflows.py +++ b/bbot/modules/github_workflows.py @@ -48,17 +48,21 @@ async def handle_event(self, event): run_id = run.get("id") self.log.debug(f"Downloading logs for {workflow_name}/{run_id} in {owner}/{repo}") for log in await self.download_run_logs(owner, repo, run_id): + workflow_url = f"https://github.com/{owner}/{repo}/actions/runs/{run_id}" logfile_event = self.make_event( { "path": str(log), - "description": f"Workflow run logs from https://github.com/{owner}/{repo}/actions/runs/{run_id}", + "description": f"Workflow run logs from {workflow_url}", }, "FILESYSTEM", tags=["textfile"], - source=event, + parent=event, ) logfile_event.scope_distance = event.scope_distance - await self.emit_event(logfile_event) + await self.emit_event( + logfile_event, + context=f"{{module}} downloaded workflow run logs from {workflow_url} to {{event.type}}: {log}", + ) async def get_workflows(self, owner, repo): workflows = [] diff --git a/bbot/modules/gitlab.py b/bbot/modules/gitlab.py index 7aa91190d..3404f3ba3 100644 --- a/bbot/modules/gitlab.py +++ b/bbot/modules/gitlab.py @@ -4,7 +4,7 @@ class gitlab(BaseModule): watched_events = ["HTTP_RESPONSE", "TECHNOLOGY", "SOCIAL"] produced_events = ["TECHNOLOGY", "SOCIAL", "CODE_REPOSITORY", "FINDING"] - flags = ["active", "safe"] + flags = ["active", "safe", "code-enum"] meta = { "description": "Detect GitLab instances and query them for repositories", "created_date": "2024-03-11", @@ -51,14 +51,19 @@ async def handle_http_response(self, event): # HTTP_RESPONSE --> FINDING headers = event.data.get("header", {}) if "x_gitlab_meta" in headers: - url = event.parsed._replace(path="/").geturl() + url = event.parsed_url._replace(path="/").geturl() await self.emit_event( - {"host": str(event.host), "technology": "GitLab", "url": url}, "TECHNOLOGY", source=event + {"host": str(event.host), "technology": "GitLab", "url": url}, + "TECHNOLOGY", + parent=event, + context=f"{{module}} detected {{event.type}}: GitLab at {url}", ) + description = f"GitLab server at {event.host}" await self.emit_event( - {"host": str(event.host), "description": f"GitLab server at {event.host}"}, + {"host": str(event.host), "description": description}, "FINDING", - source=event, + parent=event, + context=f"{{module}} detected {{event.type}}: {description}", ) async def handle_technology(self, event): @@ -93,9 +98,11 @@ async def handle_projects_url(self, projects_url, event): for project in await self.gitlab_json_request(projects_url): project_url = project.get("web_url", "") if project_url: - code_event = self.make_event({"url": project_url}, "CODE_REPOSITORY", tags="git", source=event) + code_event = self.make_event({"url": project_url}, "CODE_REPOSITORY", tags="git", parent=event) code_event.scope_distance = event.scope_distance - await self.emit_event(code_event) + await self.emit_event( + code_event, context=f"{{module}} enumerated projects and found {{event.type}} at {project_url}" + ) namespace = project.get("namespace", {}) if namespace: await self.handle_namespace(namespace, event) @@ -124,10 +131,13 @@ async def handle_namespace(self, namespace, event): social_event = self.make_event( {"platform": "gitlab", "profile_name": namespace_path, "url": namespace_url}, "SOCIAL", - source=event, + parent=event, ) social_event.scope_distance = event.scope_distance - await self.emit_event(social_event) + await self.emit_event( + social_event, + context=f'{{module}} found GitLab namespace ({{event.type}}) "{namespace_name}" at {namespace_url}', + ) def get_base_url(self, event): base_url = event.data.get("url", "") diff --git a/bbot/modules/gowitness.py b/bbot/modules/gowitness.py index 4f3a83cc7..93950e340 100644 --- a/bbot/modules/gowitness.py +++ b/bbot/modules/gowitness.py @@ -20,7 +20,7 @@ class gowitness(BaseModule): "resolution_x": 1440, "resolution_y": 900, "output_path": "", - "social": True, + "social": False, "idle_timeout": 1800, } options_desc = { @@ -33,45 +33,8 @@ class gowitness(BaseModule): "social": "Whether to screenshot social media webpages", "idle_timeout": "Skip the current gowitness batch if it stalls for longer than this many seconds", } + deps_common = ["chromium"] deps_ansible = [ - { - "name": "Install Chromium (Non-Debian)", - "package": {"name": "chromium", "state": "present"}, - "become": True, - "when": "ansible_facts['os_family'] != 'Debian'", - "ignore_errors": True, - }, - { - "name": "Install Chromium dependencies (Debian)", - "package": { - "name": "libasound2,libatk-bridge2.0-0,libatk1.0-0,libcairo2,libcups2,libdrm2,libgbm1,libnss3,libpango-1.0-0,libxcomposite1,libxdamage1,libxfixes3,libxkbcommon0,libxrandr2", - "state": "present", - }, - "become": True, - "when": "ansible_facts['os_family'] == 'Debian'", - "ignore_errors": True, - }, - { - "name": "Get latest Chromium version (Debian)", - "uri": { - "url": "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2FLAST_CHANGE?alt=media", - "return_content": True, - }, - "register": "chromium_version", - "when": "ansible_facts['os_family'] == 'Debian'", - "ignore_errors": True, - }, - { - "name": "Download Chromium (Debian)", - "unarchive": { - "src": "https://www.googleapis.com/download/storage/v1/b/chromium-browser-snapshots/o/Linux_x64%2F{{ chromium_version.content }}%2Fchrome-linux.zip?alt=media", - "remote_src": True, - "dest": "#{BBOT_TOOLS}", - "creates": "#{BBOT_TOOLS}/chrome-linux", - }, - "when": "ansible_facts['os_family'] == 'Debian'", - "ignore_errors": True, - }, { "name": "Download gowitness", "get_url": { @@ -178,31 +141,45 @@ async def handle_batch(self, *events): final_url = screenshot["final_url"] filename = self.screenshot_path / screenshot["filename"] webscreenshot_data = {"filename": str(filename), "url": final_url} - source_event = event_dict[url] - await self.emit_event(webscreenshot_data, "WEBSCREENSHOT", source=source_event) + parent_event = event_dict[url] + await self.emit_event( + webscreenshot_data, + "WEBSCREENSHOT", + parent=parent_event, + context=f"{{module}} visited {final_url} and saved {{event.type}} to {filename}", + ) # emit URLs for url, row in self.new_network_logs.items(): ip = row["ip"] status_code = row["status_code"] - tags = [f"status-{status_code}", f"ip-{ip}"] + tags = [f"status-{status_code}", f"ip-{ip}", "spider-danger"] _id = row["url_id"] - source_url = self.screenshots_taken[_id] - source_event = event_dict[source_url] - if self.helpers.is_spider_danger(source_event, url): - tags.append("spider-danger") + parent_url = self.screenshots_taken[_id] + parent_event = event_dict[parent_url] if url and url.startswith("http"): - await self.emit_event(url, "URL_UNVERIFIED", source=source_event, tags=tags) + await self.emit_event( + url, + "URL_UNVERIFIED", + parent=parent_event, + tags=tags, + context=f"{{module}} visited {{event.type}}: {url}", + ) # emit technologies for _, row in self.new_technologies.items(): - source_id = row["url_id"] - source_url = self.screenshots_taken[source_id] - source_event = event_dict[source_url] + parent_id = row["url_id"] + parent_url = self.screenshots_taken[parent_id] + parent_event = event_dict[parent_url] technology = row["value"] - tech_data = {"technology": technology, "url": source_url, "host": str(source_event.host)} - await self.emit_event(tech_data, "TECHNOLOGY", source=source_event) + tech_data = {"technology": technology, "url": parent_url, "host": str(parent_event.host)} + await self.emit_event( + tech_data, + "TECHNOLOGY", + parent=parent_event, + context=f"{{module}} visited {parent_url} and found {{event.type}}: {technology}", + ) def construct_command(self): # base executable diff --git a/bbot/modules/host_header.py b/bbot/modules/host_header.py index bba8f0aca..00dd640ba 100644 --- a/bbot/modules/host_header.py +++ b/bbot/modules/host_header.py @@ -1,5 +1,5 @@ +from bbot.errors import InteractshError from bbot.modules.base import BaseModule -from bbot.core.errors import InteractshError class host_header(BaseModule): @@ -44,14 +44,16 @@ async def interactsh_callback(self, r): matched_event = match[0] matched_technique = match[1] + protocol = r.get("protocol").upper() await self.emit_event( { "host": str(matched_event.host), "url": matched_event.data["url"], - "description": f"Spoofed Host header ({matched_technique}) [{r.get('protocol').upper()}] interaction", + "description": f"Spoofed Host header ({matched_technique}) [{protocol}] interaction", }, "FINDING", matched_event, + context=f"{{module}} spoofed host header and induced {{event.type}}: {protocol} interaction", ) else: # this is likely caused by something trying to resolve the base domain first and can be ignored @@ -78,16 +80,18 @@ async def cleanup(self): async def handle_event(self, event): # get any set-cookie responses from the response and add them to the request + url = event.data["url"] added_cookies = {} - for header, header_value in event.data["header-dict"].items(): - if header_value.lower() == "set-cookie": - header_split = header_value.split("=") - try: - added_cookies = {header_split[0]: header_split[1]} - except IndexError: - self.debug(f"failed to parse cookie from string {header_value}") + for header, header_values in event.data["header-dict"].items(): + for header_value in header_values: + if header_value.lower() == "set-cookie": + header_split = header_value.split("=") + try: + added_cookies = {header_split[0]: header_split[1]} + except IndexError: + self.debug(f"failed to parse cookie from string {header_value}") domain_reflections = [] @@ -97,7 +101,7 @@ async def handle_event(self, event): subdomain_tag = self.rand_string(4, digits=False) self.subdomain_tags[subdomain_tag] = (event, technique_description) output = await self.helpers.curl( - url=event.data["url"], + url=url, headers={"Host": f"{subdomain_tag}.{self.domain}"}, ignore_bbot_global_settings=True, cookies=added_cookies, @@ -111,8 +115,8 @@ async def handle_event(self, event): subdomain_tag = self.rand_string(4, digits=False) self.subdomain_tags[subdomain_tag] = (event, technique_description) output = await self.helpers.curl( - url=event.data["url"], - path_override=event.data["url"], + url=url, + path_override=url, cookies=added_cookies, ) @@ -122,7 +126,7 @@ async def handle_event(self, event): # duplicate host header tolerance technique_description = "duplicate host header tolerance" output = await self.helpers.curl( - url=event.data["url"], + url=url, # Sending a blank HOST first as a hack to trick curl. This makes it no longer an "internal header", thereby allowing for duplicates # 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)]}, @@ -132,14 +136,16 @@ async def handle_event(self, event): split_output = output.split("\n") if " 4" in split_output: + description = f"Duplicate Host Header Tolerated" await self.emit_event( { "host": str(event.host), - "url": event.data["url"], - "description": f"Duplicate Host Header Tolerated", + "url": url, + "description": description, }, "FINDING", event, + context=f"{{module}} scanned {event.data['url']} and identified {{event.type}}: {description}", ) # host header overrides @@ -163,7 +169,7 @@ async def handle_event(self, event): override_headers[oh] = f"{subdomain_tag}.{self.domain}" output = await self.helpers.curl( - url=event.data["url"], + url=url, headers=override_headers, cookies=added_cookies, ) @@ -172,12 +178,14 @@ async def handle_event(self, event): # emit all the domain reflections we found for dr in domain_reflections: + description = f"Possible Host header injection. Injection technique: {dr}" await self.emit_event( { "host": str(event.host), - "url": event.data["url"], - "description": f"Possible Host header injection. Injection technique: {dr}", + "url": url, + "description": description, }, "FINDING", event, + context=f"{{module}} scanned {url} and identified {{event.type}}: {description}", ) diff --git a/bbot/modules/httpx.py b/bbot/modules/httpx.py index 2c2fcfe72..eb0cd376f 100644 --- a/bbot/modules/httpx.py +++ b/bbot/modules/httpx.py @@ -4,13 +4,12 @@ import subprocess from pathlib import Path from bbot.modules.base import BaseModule -from bbot.core.helpers.web import is_login_page class httpx(BaseModule): watched_events = ["OPEN_TCP_PORT", "URL_UNVERIFIED", "URL"] produced_events = ["URL", "HTTP_RESPONSE"] - flags = ["active", "safe", "web-basic", "web-thorough", "social-enum", "subdomain-enum", "cloud-enum"] + flags = ["active", "safe", "web-basic", "social-enum", "subdomain-enum", "cloud-enum"] meta = { "description": "Visit webpages. Many other modules rely on httpx", "created_date": "2022-07-08", @@ -27,7 +26,7 @@ class httpx(BaseModule): } options_desc = { "threads": "Number of httpx threads to use", - "in_scope_only": "Only visit web resources that are in scope.", + "in_scope_only": "Only visit web reparents that are in scope.", "version": "httpx version", "max_response_size": "Max response size in bytes", "store_responses": "Save raw HTTP responses to scan folder", @@ -46,17 +45,15 @@ class httpx(BaseModule): ] scope_distance_modifier = 1 + _shuffle_incoming_queue = False _batch_size = 500 _priority = 2 async def setup(self): self.threads = self.config.get("threads", 50) - self.timeout = self.scan.config.get("httpx_timeout", 5) - self.retries = self.scan.config.get("httpx_retries", 1) self.max_response_size = self.config.get("max_response_size", 5242880) self.store_responses = self.config.get("store_responses", False) self.probe_all_ips = self.config.get("probe_all_ips", False) - self.visited = set() self.httpx_tempdir_regex = re.compile(r"^httpx\d+$") return True @@ -70,34 +67,42 @@ async def filter_event(self, event): if event.module == self: return False, "event is from self" - if "spider-danger" in event.tags: - return False, "event has spider danger" + if "spider-max" in event.tags: + return False, "event exceeds spidering limits" # scope filtering in_scope_only = self.config.get("in_scope_only", True) safe_to_visit = "httpx-safe" in event.tags if not safe_to_visit and (in_scope_only and not self.scan.in_scope(event)): return False, "event is not in scope" - return True + def make_url_metadata(self, event): + has_spider_max = "spider-max" in event.tags + url_hash = None + if event.type.startswith("URL"): + # we NEED the port, otherwise httpx will try HTTPS even for HTTP URLs + url = event.with_port().geturl() + if event.parsed_url.path == "/": + url_hash = hash((event.host, event.port, has_spider_max)) + else: + url = str(event.data) + url_hash = hash((event.host, event.port, has_spider_max)) + if url_hash == None: + url_hash = hash((url, has_spider_max)) + return url, url_hash + + def _incoming_dedup_hash(self, event): + + url, url_hash = self.make_url_metadata(event) + return url_hash + async def handle_batch(self, *events): stdin = {} - for e in events: - url_hash = None - if e.type.startswith("URL"): - # we NEED the port, otherwise httpx will try HTTPS even for HTTP URLs - url = e.with_port().geturl() - if e.parsed.path == "/": - url_hash = hash((e.host, e.port)) - else: - url = str(e.data) - url_hash = hash((e.host, e.port)) - - if url_hash not in self.visited: - stdin[url] = e - if url_hash is not None: - self.visited.add(url_hash) + + for event in events: + url, url_hash = self.make_url_metadata(event) + stdin[url] = event if not stdin: return @@ -110,9 +115,9 @@ async def handle_batch(self, *events): "-threads", self.threads, "-timeout", - self.timeout, + self.scan.httpx_timeout, "-retries", - self.retries, + self.scan.httpx_retries, "-header", f"User-Agent: {self.scan.useragent}", "-response-size-to-read", @@ -131,9 +136,9 @@ async def handle_batch(self, *events): if self.probe_all_ips: command += ["-probe-all-ips"] - for hk, hv in self.scan.config.get("http_headers", {}).items(): + for hk, hv in self.scan.custom_http_headers.items(): command += ["-header", f"{hk}: {hv}"] - proxy = self.scan.config.get("http_proxy", "") + proxy = self.scan.http_proxy if proxy: command += ["-http-proxy", proxy] async for line in self.run_process_live(command, input=list(stdin), stderr=subprocess.DEVNULL): @@ -149,15 +154,15 @@ async def handle_batch(self, *events): self.debug(f'No HTTP status code for "{url}"') continue - source_event = stdin.get(j.get("input", ""), None) + parent_event = stdin.get(j.get("input", ""), None) - if source_event is None: - self.warning(f"Unable to correlate source event from: {line}") + if parent_event is None: + self.warning(f"Unable to correlate parent event from: {line}") continue # discard 404s from unverified URLs path = j.get("path", "/") - if source_event.type == "URL_UNVERIFIED" and status_code in (404,) and path != "/": + if parent_event.type == "URL_UNVERIFIED" and status_code in (404,) and path != "/": self.debug(f'Discarding 404 from "{url}"') continue @@ -167,20 +172,38 @@ async def handle_batch(self, *events): if httpx_ip: tags.append(f"ip-{httpx_ip}") # detect login pages - if is_login_page(j.get("body", "")): + if self.helpers.web.is_login_page(j.get("body", "")): tags.append("login-page") # grab title title = self.helpers.tagify(j.get("title", ""), maxlen=30) if title: tags.append(f"http-title-{title}") - url_event = self.make_event(url, "URL", source_event, tags=tags) + + url_context = "{module} visited {event.parent.data} and got status code {event.http_status}" + if parent_event.type == "OPEN_TCP_PORT": + url_context += " at {event.data}" + + url_event = self.make_event( + url, + "URL", + parent_event, + tags=tags, + context=url_context, + ) if url_event: - if url_event != source_event: + if url_event != parent_event: await self.emit_event(url_event) - else: - url_event._resolved.set() # HTTP response - await self.emit_event(j, "HTTP_RESPONSE", url_event, tags=url_event.tags) + content_type = j.get("header", {}).get("content_type", "unspecified").split(";")[0] + content_length = j.get("content_length", 0) + content_length = self.helpers.bytes_to_human(content_length) + await self.emit_event( + j, + "HTTP_RESPONSE", + url_event, + tags=url_event.tags, + context=f"HTTP_RESPONSE was {content_length} with {content_type} content type", + ) for tempdir in Path(tempfile.gettempdir()).iterdir(): if tempdir.is_dir() and self.httpx_tempdir_regex.match(tempdir.name): diff --git a/bbot/modules/hunt.py b/bbot/modules/hunt.py index a86c66cef..649eaaa19 100644 --- a/bbot/modules/hunt.py +++ b/bbot/modules/hunt.py @@ -1,7 +1,6 @@ # adapted from https://github.com/bugcrowd/HUNT from bbot.modules.base import BaseModule -from bbot.core.helpers.misc import extract_params_html hunt_param_dict = { "Command Injection": [ @@ -272,25 +271,24 @@ class hunt(BaseModule): - watched_events = ["HTTP_RESPONSE"] + watched_events = ["WEB_PARAMETER"] produced_events = ["FINDING"] flags = ["active", "safe", "web-thorough"] meta = { "description": "Watch for commonly-exploitable HTTP parameters", - "created_date": "2022-07-20", "author": "@liquidsec", + "created_date": "2022-07-20", } # accept all events regardless of scope distance scope_distance_modifier = None async def handle_event(self, event): - body = event.data.get("body", "") - for p in extract_params_html(body): - for k in hunt_param_dict.keys(): - if p.lower() in hunt_param_dict[k]: - description = f"Found potential {k.upper()} parameter [{p}]" - data = {"host": str(event.host), "description": description} - url = event.data.get("url", "") - if url: - data["url"] = url - await self.emit_event(data, "FINDING", event) + p = event.data["name"] + for k in hunt_param_dict.keys(): + if p.lower() in hunt_param_dict[k]: + description = f"Found potential {k.upper()} parameter [{p}]" + data = {"host": str(event.host), "description": description} + url = event.data.get("url", "") + if url: + data["url"] = url + await self.emit_event(data, "FINDING", event) diff --git a/bbot/modules/hunterio.py b/bbot/modules/hunterio.py index 2f4d22bad..7396df481 100644 --- a/bbot/modules/hunterio.py +++ b/bbot/modules/hunterio.py @@ -31,14 +31,27 @@ async def handle_event(self, event): if email: email_event = self.make_event(email, "EMAIL_ADDRESS", event) if email_event: - await self.emit_event(email_event) + await self.emit_event( + email_event, + context=f'{{module}} queried Hunter.IO API for "{query}" and found {{event.type}}: {{event.data}}', + ) for source in sources: domain = source.get("domain", "") if domain: - await self.emit_event(domain, "DNS_NAME", email_event) + await self.emit_event( + domain, + "DNS_NAME", + email_event, + context=f"{{module}} originally found {email} at {{event.type}}: {{event.data}}", + ) url = source.get("uri", "") if url: - await self.emit_event(url, "URL_UNVERIFIED", email_event) + await self.emit_event( + url, + "URL_UNVERIFIED", + email_event, + context=f"{{module}} originally found {email} at {{event.type}}: {{event.data}}", + ) async def query(self, query): emails = [] diff --git a/bbot/modules/iis_shortnames.py b/bbot/modules/iis_shortnames.py index 86a98dc86..d5e71568e 100644 --- a/bbot/modules/iis_shortnames.py +++ b/bbot/modules/iis_shortnames.py @@ -16,7 +16,7 @@ class IISShortnamesError(Exception): class iis_shortnames(BaseModule): watched_events = ["URL"] produced_events = ["URL_HINT"] - flags = ["active", "safe", "web-basic", "web-thorough", "iis-shortnames"] + flags = ["active", "safe", "web-basic", "iis-shortnames"] meta = { "description": "Check for IIS shortname vulnerability", "created_date": "2022-04-15", @@ -29,7 +29,7 @@ class iis_shortnames(BaseModule): } in_scope_only = True - _max_event_handlers = 8 + _module_threads = 8 async def detect(self, target): technique = None @@ -38,10 +38,22 @@ async def detect(self, target): control_url = f"{target}{random_string}*~1*/a.aspx" test_url = f"{target}*~1*/a.aspx" + urls_and_kwargs = [] for method in ["GET", "POST", "OPTIONS", "DEBUG", "HEAD", "TRACE"]: - control = await self.helpers.request(method=method, url=control_url, allow_redirects=False, timeout=10) - test = await self.helpers.request(method=method, url=test_url, allow_redirects=False, timeout=10) - if (control != None) and (test != None): + kwargs = dict(method=method, allow_redirects=False, timeout=10) + urls_and_kwargs.append((control_url, kwargs, method)) + urls_and_kwargs.append((test_url, kwargs, method)) + + results = {} + async for url, kwargs, method, response in self.helpers.request_custom_batch(urls_and_kwargs): + try: + results[method][url] = response + except KeyError: + results[method] = {url: response} + for method, result in results.items(): + control = results[method].get(control_url, None) + test = results[method].get(test_url, None) + if (result != None) and (test != None) and (control != None): if control.status_code != test.status_code: technique = f"{str(control.status_code)}/{str(test.status_code)} HTTP Code" detections.append((method, test.status_code, technique)) @@ -112,32 +124,23 @@ async def threaded_request(self, method, url, affirmative_status_code, c): async def solve_valid_chars(self, method, target, affirmative_status_code): confirmed_chars = [] confirmed_exts = [] - tasks = [] suffix = "/a.aspx" + urls_and_kwargs = [] + kwargs = dict(method=method, allow_redirects=False, retries=2, timeout=10) for c in valid_chars: - payload = encode_all(f"*{c}*~1*") - url = f"{target}{payload}{suffix}" - task = self.threaded_request(method, url, affirmative_status_code, c) - tasks.append(task) - - async for task in self.helpers.as_completed(tasks): - result, c = await task - if result: - confirmed_chars.append(c) - - tasks = [] - - for c in valid_chars: - payload = encode_all(f"*~1*{c}*") - url = f"{target}{payload}{suffix}" - task = self.threaded_request(method, url, affirmative_status_code, c) - tasks.append(task) + for file_part in ("stem", "ext"): + payload = encode_all(f"*{c}*~1*") + url = f"{target}{payload}{suffix}" + urls_and_kwargs.append((url, kwargs, (c, file_part))) - async for task in self.helpers.as_completed(tasks): - result, c = await task - if result: - confirmed_exts.append(c) + async for url, kwargs, (c, file_part), response in self.helpers.request_custom_batch(urls_and_kwargs): + if response is not None: + if response.status_code == affirmative_status_code: + if file_part == "stem": + confirmed_chars.append(c) + elif file_part == "ext": + confirmed_exts.append(c) return confirmed_chars, confirmed_exts @@ -156,53 +159,55 @@ async def solve_shortname_recursive( url_hint_list = [] found_results = False - tasks = [] - cl = ext_char_list if extension_mode == True else char_list + urls_and_kwargs = [] + for c in cl: suffix = "/a.aspx" wildcard = "*" if extension_mode else "*~1*" payload = encode_all(f"{prefix}{c}{wildcard}") url = f"{target}{payload}{suffix}" - task = self.threaded_request(method, url, affirmative_status_code, c) - tasks.append(task) - - async for task in self.helpers.as_completed(tasks): - result, c = await task - if result: - found_results = True - node_count += 1 - safety_counter.counter += 1 - if safety_counter.counter > 3000: - raise IISShortnamesError(f"Exceeded safety counter threshold ({safety_counter.counter})") - self.verbose(f"node_count: {str(node_count)} for node: {target}") - if node_count > self.config.get("max_node_count"): - self.warning( - f"iis_shortnames: max_node_count ({str(self.config.get('max_node_count'))}) exceeded for node: {target}. Affected branch will be terminated." + kwargs = dict(method=method) + urls_and_kwargs.append((url, kwargs, c)) + + async for url, kwargs, c, response in self.helpers.request_custom_batch(urls_and_kwargs): + if response is not None: + if response.status_code == affirmative_status_code: + found_results = True + node_count += 1 + safety_counter.counter += 1 + if safety_counter.counter > 3000: + raise IISShortnamesError(f"Exceeded safety counter threshold ({safety_counter.counter})") + self.verbose(f"node_count: {str(node_count)} for node: {target}") + if node_count > self.config.get("max_node_count"): + self.warning( + f"iis_shortnames: max_node_count ({str(self.config.get('max_node_count'))}) exceeded for node: {target}. Affected branch will be terminated." + ) + return url_hint_list + + # check to make sure the file isn't shorter than 6 characters + wildcard = "~1*" + payload = encode_all(f"{prefix}{c}{wildcard}") + url = f"{target}{payload}{suffix}" + r = await self.helpers.request( + method=method, url=url, allow_redirects=False, retries=2, timeout=10 + ) + if r is not None: + if r.status_code == affirmative_status_code: + url_hint_list.append(f"{prefix}{c}") + + url_hint_list += await self.solve_shortname_recursive( + safety_counter, + method, + target, + f"{prefix}{c}", + affirmative_status_code, + char_list, + ext_char_list, + extension_mode, + node_count=node_count, ) - return url_hint_list - - # check to make sure the file isn't shorter than 6 characters - wildcard = "~1*" - payload = encode_all(f"{prefix}{c}{wildcard}") - url = f"{target}{payload}{suffix}" - r = await self.helpers.request(method=method, url=url, allow_redirects=False, retries=2, timeout=10) - if r is not None: - if r.status_code == affirmative_status_code: - url_hint_list.append(f"{prefix}{c}") - - url_hint_list += await self.solve_shortname_recursive( - safety_counter, - method, - target, - f"{prefix}{c}", - affirmative_status_code, - char_list, - ext_char_list, - extension_mode, - node_count=node_count, - ) if len(prefix) > 0 and found_results == False: url_hint_list.append(f"{prefix}") self.verbose(f"Found new (possibly partial) URL_HINT: {prefix} from node {target}") @@ -228,6 +233,7 @@ class safety_counter_obj: {"severity": "LOW", "host": str(event.host), "url": normalized_url, "description": description}, "VULNERABILITY", event, + context=f"{{module}} detected low {{event.type}}: IIS shortname enumeration", ) if not self.config.get("detect_only"): for detection in detections: @@ -317,7 +323,13 @@ class safety_counter_obj: hint_type = "shortname-file" else: hint_type = "shortname-directory" - await self.emit_event(f"{normalized_url}/{url_hint}", "URL_HINT", event, tags=[hint_type]) + await self.emit_event( + f"{normalized_url}/{url_hint}", + "URL_HINT", + event, + tags=[hint_type], + context=f"{{module}} enumerated shortnames at {normalized_url} and found {{event.type}}: {url_hint}", + ) async def filter_event(self, event): if "dir" in event.tags: diff --git a/bbot/modules/internal/base.py b/bbot/modules/internal/base.py index 9e7967b42..8ef1b7fd9 100644 --- a/bbot/modules/internal/base.py +++ b/bbot/modules/internal/base.py @@ -9,13 +9,6 @@ class BaseInternalModule(BaseModule): # Priority, 1-5, lower numbers == higher priority _priority = 3 - @property - def config(self): - config = self.scan.config.get("internal_modules", {}).get(self.name, {}) - if config is None: - config = {} - return config - @property def log(self): if self._log is None: diff --git a/bbot/modules/internal/cloudcheck.py b/bbot/modules/internal/cloudcheck.py new file mode 100644 index 000000000..45011c509 --- /dev/null +++ b/bbot/modules/internal/cloudcheck.py @@ -0,0 +1,83 @@ +from bbot.modules.base import InterceptModule + + +class CloudCheck(InterceptModule): + watched_events = ["*"] + meta = {"description": "Tag events by cloud provider, identify cloud resources like storage buckets"} + scope_distance_modifier = 1 + _priority = 3 + + async def setup(self): + self.dummy_modules = None + return True + + def make_dummy_modules(self): + self.dummy_modules = {} + for provider_name, provider in self.helpers.cloud.providers.items(): + self.dummy_modules[provider_name] = self.scan._make_dummy_module(f"cloud_{provider_name}", _type="scan") + + async def filter_event(self, event): + if (not event.host) or (event.type in ("IP_RANGE",)): + return False, "event does not have host attribute" + return True + + async def handle_event(self, event, kwargs): + # don't hold up the event loop loading cloud IPs etc. + if self.dummy_modules is None: + self.make_dummy_modules() + # cloud tagging by hosts + hosts_to_check = set(str(s) for s in event.resolved_hosts) + hosts_to_check.add(str(event.host_original)) + for host in hosts_to_check: + for provider, provider_type, subnet in self.helpers.cloudcheck(host): + if provider: + event.add_tag(f"{provider_type}-{provider}") + + found = set() + # look for cloud assets in hosts, http responses + # loop through each provider + for provider in self.helpers.cloud.providers.values(): + provider_name = provider.name.lower() + base_kwargs = dict( + parent=event, tags=[f"{provider.provider_type}-{provider_name}"], _provider=provider_name + ) + # loop through the provider's regex signatures, if any + for event_type, sigs in provider.signatures.items(): + if event_type != "STORAGE_BUCKET": + raise ValueError(f'Unknown cloudcheck event type "{event_type}"') + base_kwargs["event_type"] = event_type + for sig in sigs: + matches = [] + if event.type == "HTTP_RESPONSE": + matches = await self.helpers.re.findall(sig, event.data.get("body", "")) + elif event.type.startswith("DNS_NAME"): + for host in hosts_to_check: + match = sig.match(host) + if match: + matches.append(match.groups()) + for match in matches: + if not match in found: + found.add(match) + + _kwargs = dict(base_kwargs) + event_type_tag = f"cloud-{event_type}" + _kwargs["tags"].append(event_type_tag) + if event.type.startswith("DNS_NAME"): + event.add_tag(event_type_tag) + + if event_type == "STORAGE_BUCKET": + bucket_name, bucket_domain = match + bucket_url = f"https://{bucket_name}.{bucket_domain}" + _kwargs["data"] = { + "name": bucket_name, + "url": bucket_url, + "context": f"{{module}} analyzed {event.type} and found {{event.type}}: {bucket_url}", + } + await self.emit_event(**_kwargs) + + async def emit_event(self, *args, **kwargs): + provider_name = kwargs.pop("_provider") + dummy_module = self.dummy_modules[provider_name] + event = dummy_module.make_event(*args, **kwargs) + if event: + await super().emit_event(event) diff --git a/bbot/modules/internal/dnsresolve.py b/bbot/modules/internal/dnsresolve.py new file mode 100644 index 000000000..45307d50d --- /dev/null +++ b/bbot/modules/internal/dnsresolve.py @@ -0,0 +1,281 @@ +import ipaddress +from contextlib import suppress +from cachetools import LRUCache + +from bbot.errors import ValidationError +from bbot.core.helpers.dns.engine import all_rdtypes +from bbot.core.helpers.async_helpers import NamedLock +from bbot.modules.base import InterceptModule, BaseModule +from bbot.core.helpers.dns.helpers import extract_targets + + +class DNSResolve(InterceptModule): + watched_events = ["*"] + _priority = 1 + scope_distance_modifier = None + + class HostModule(BaseModule): + _name = "host" + _type = "internal" + + def _outgoing_dedup_hash(self, event): + return hash((event, self.name, event.always_emit)) + + @property + def module_threads(self): + return self.dns_config.get("threads", 25) + + async def setup(self): + self.dns_config = self.scan.config.get("dns", {}) + self.dns_disable = self.dns_config.get("disable", False) + if self.dns_disable: + return None, "DNS resolution is disabled in the config" + + self.minimal = self.dns_config.get("minimal", False) + self.dns_search_distance = max(0, int(self.dns_config.get("search_distance", 1))) + self._emit_raw_records = None + + # event resolution cache + self._event_cache = LRUCache(maxsize=10000) + self._event_cache_locks = NamedLock() + + self.host_module = self.HostModule(self.scan) + + return True + + @property + def _dns_search_distance(self): + return max(self.scan.scope_search_distance, self.dns_search_distance) + + @property + def emit_raw_records(self): + if self._emit_raw_records is None: + watching_raw_records = any( + ["RAW_DNS_RECORD" in m.get_watched_events() for m in self.scan.modules.values()] + ) + omitted_event_types = self.scan.config.get("omit_event_types", []) + omit_raw_records = "RAW_DNS_RECORD" in omitted_event_types + self._emit_raw_records = watching_raw_records or not omit_raw_records + return self._emit_raw_records + + async def filter_event(self, event): + if (not event.host) or (event.type in ("IP_RANGE",)): + return False, "event does not have host attribute" + return True + + async def handle_event(self, event, kwargs): + dns_tags = set() + dns_children = dict() + event_whitelisted = False + event_blacklisted = False + emit_children = False + + event_host = str(event.host) + event_host_hash = hash(str(event.host)) + event_is_ip = self.helpers.is_ip(event.host) + + # we do DNS resolution inside a lock to make sure we don't duplicate work + # once the resolution happens, it will be cached so it doesn't need to happen again + async with self._event_cache_locks.lock(event_host_hash): + try: + # try to get from cache + dns_tags, dns_children, event_whitelisted, event_blacklisted = self._event_cache[event_host_hash] + except KeyError: + rdtypes_to_resolve = () + if event_is_ip: + if not self.minimal: + rdtypes_to_resolve = ("PTR",) + else: + if self.minimal: + rdtypes_to_resolve = ("A", "AAAA", "CNAME") + else: + rdtypes_to_resolve = all_rdtypes + + # if missing from cache, do DNS resolution + queries = [(event_host, rdtype) for rdtype in rdtypes_to_resolve] + error_rdtypes = [] + async for (query, rdtype), (answer, errors) in self.helpers.dns.resolve_raw_batch(queries): + if self.emit_raw_records and rdtype not in ("A", "AAAA", "CNAME", "PTR"): + await self.emit_event( + {"host": str(event_host), "type": rdtype, "answer": answer.to_text()}, + "RAW_DNS_RECORD", + parent=event, + tags=[f"{rdtype.lower()}-record"], + context=f"{rdtype} lookup on {{event.parent.host}} produced {{event.type}}", + ) + if errors: + error_rdtypes.append(rdtype) + for _rdtype, host in extract_targets(answer): + dns_tags.add(f"{rdtype.lower()}-record") + try: + dns_children[_rdtype].add(host) + except KeyError: + dns_children[_rdtype] = {host} + + for rdtype in error_rdtypes: + if rdtype not in dns_children: + dns_tags.add(f"{rdtype.lower()}-error") + + if not dns_children and not event_is_ip: + dns_tags.add("unresolved") + + for rdtype, children in dns_children.items(): + if event_blacklisted: + break + for host in children: + # whitelisting / blacklisting based on resolved hosts + if rdtype in ("A", "AAAA", "CNAME"): + # having a CNAME to an in-scope resource doesn't make you in-scope + if not event_whitelisted and rdtype != "CNAME": + with suppress(ValidationError): + if self.scan.whitelisted(host): + event_whitelisted = True + # CNAME to a blacklisted resource, means you're blacklisted + with suppress(ValidationError): + if self.scan.blacklisted(host): + dns_tags.add("blacklisted") + event_blacklisted = True + break + + # check for private IPs + try: + ip = ipaddress.ip_address(host) + if ip.is_private: + dns_tags.add("private-ip") + except ValueError: + continue + + # only emit DNS children if we haven't seen this host before + emit_children = (not self.minimal) and (event_host_hash not in self._event_cache) + + # store results in cache + self._event_cache[event_host_hash] = dns_tags, dns_children, event_whitelisted, event_blacklisted + + # abort if the event resolves to something blacklisted + if event_blacklisted: + event.add_tag("blacklisted") + return False, f"it has a blacklisted DNS record" + + # set resolved_hosts attribute + for rdtype, children in dns_children.items(): + if rdtype in ("A", "AAAA", "CNAME"): + for host in children: + event.resolved_hosts.add(host) + + # set dns_children attribute + event.dns_children = dns_children + + # if the event resolves to an in-scope IP, set its scope distance to 0 + if event_whitelisted: + self.debug(f"Making {event} in-scope because it resolves to an in-scope resource") + event.scope_distance = 0 + + # check for wildcards, only if the event resolves to something that isn't an IP + if (not event_is_ip) and (dns_children): + if event.scope_distance <= self.scan.scope_search_distance: + await self.handle_wildcard_event(event) + + # kill runaway DNS chains + dns_resolve_distance = getattr(event, "dns_resolve_distance", 0) + if dns_resolve_distance >= self.helpers.dns.runaway_limit: + self.debug( + f"Skipping DNS children for {event} because their DNS resolve distances would be greater than the configured value for this scan ({self.helpers.dns.runaway_limit})" + ) + dns_children = {} + + # if the event is a DNS_NAME or IP, tag with "a-record", "ptr-record", etc. + if event.type in ("DNS_NAME", "IP_ADDRESS"): + for tag in dns_tags: + event.add_tag(tag) + + # If the event is unresolved, change its type to DNS_NAME_UNRESOLVED + if event.type == "DNS_NAME" and "unresolved" in event.tags: + event.type = "DNS_NAME_UNRESOLVED" + + # speculate DNS_NAMES and IP_ADDRESSes from other event types + parent_event = event + if ( + event.host + and event.type not in ("DNS_NAME", "DNS_NAME_UNRESOLVED", "IP_ADDRESS", "IP_RANGE") + and not ((event.type in ("OPEN_TCP_PORT", "URL_UNVERIFIED") and str(event.module) == "speculate")) + ): + parent_event = self.scan.make_event( + event.host, + "DNS_NAME", + module=self.host_module, + parent=event, + context="{event.parent.type} has host {event.type}: {event.host}", + ) + # only emit the event if it's not already in the parent chain + if parent_event is not None and (parent_event.always_emit or parent_event not in event.get_parents()): + parent_event.scope_distance = event.scope_distance + if "target" in event.tags: + parent_event.add_tag("target") + await self.emit_event( + parent_event, + ) + + # emit DNS children + if emit_children: + in_dns_scope = -1 < event.scope_distance < self._dns_search_distance + dns_child_events = [] + if dns_children: + for rdtype, records in dns_children.items(): + module = self.scan._make_dummy_module_dns(rdtype) + for record in records: + try: + child_event = self.scan.make_event(record, "DNS_NAME", module=module, parent=parent_event) + child_event.discovery_context = ( + f"{rdtype} record for {event.host} contains {child_event.type}: {child_event.host}" + ) + # if it's a hostname and it's only one hop away, mark it as affiliate + if child_event.type == "DNS_NAME" and child_event.scope_distance == 1: + child_event.add_tag("affiliate") + if in_dns_scope or self.preset.in_scope(child_event): + dns_child_events.append(child_event) + except ValidationError as e: + self.warning( + f'Event validation failed for DNS child of {parent_event}: "{record}" ({rdtype}): {e}' + ) + for child_event in dns_child_events: + self.debug(f"Queueing DNS child for {event}: {child_event}") + await self.emit_event(child_event) + + async def handle_wildcard_event(self, event): + self.debug(f"Entering handle_wildcard_event({event}, children={event.dns_children})") + try: + event_host = str(event.host) + # check if the dns name itself is a wildcard entry + wildcard_rdtypes = await self.helpers.is_wildcard(event_host) + for rdtype, (is_wildcard, wildcard_host) in wildcard_rdtypes.items(): + wildcard_tag = "error" + if is_wildcard == True: + event.add_tag("wildcard") + wildcard_tag = "wildcard" + event.add_tag(f"{rdtype.lower()}-{wildcard_tag}") + + # wildcard event modification (www.evilcorp.com --> _wildcard.evilcorp.com) + if wildcard_rdtypes and not "target" in event.tags: + # these are the rdtypes that successfully resolve + resolved_rdtypes = set([c.upper() for c in event.dns_children]) + # these are the rdtypes that have wildcards + wildcard_rdtypes_set = set(wildcard_rdtypes) + # consider the event a full wildcard if all its records are wildcards + event_is_wildcard = False + if resolved_rdtypes: + event_is_wildcard = all(r in wildcard_rdtypes_set for r in resolved_rdtypes) + + if event_is_wildcard: + if event.type in ("DNS_NAME",) and not "_wildcard" in event.data.split("."): + wildcard_parent = self.helpers.parent_domain(event_host) + for rdtype, (_is_wildcard, _parent_domain) in wildcard_rdtypes.items(): + if _is_wildcard: + wildcard_parent = _parent_domain + break + wildcard_data = f"_wildcard.{wildcard_parent}" + if wildcard_data != event.data: + self.debug(f'Wildcard detected, changing event.data "{event.data}" --> "{wildcard_data}"') + event.data = wildcard_data + + finally: + self.debug(f"Finished handle_wildcard_event({event}, children={event.dns_children})") diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index 59d60014b..33cd3c9a4 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -1,434 +1,1032 @@ -import re +import yara +import json import html -import base64 -import jwt as j -from urllib.parse import urljoin - -from bbot.core.helpers.regexes import _email_regex, dns_name_regex +import inspect +import regex as re +from pathlib import Path +from bbot.errors import ExcavateError +import bbot.core.helpers.regexes as bbot_regexes from bbot.modules.internal.base import BaseInternalModule +from urllib.parse import urlparse, urljoin, parse_qs, urlunparse + + +def find_subclasses(obj, base_class): + """ + Finds and returns subclasses of a specified base class within an object. + + Parameters: + obj : object + The object to inspect for subclasses. + base_class : type + The base class to find subclasses of. + + Returns: + list + A list of subclasses found within the object. + + Example: + >>> class A: pass + >>> class B(A): pass + >>> class C(A): pass + >>> find_subclasses(locals(), A) + [, ] + """ + subclasses = [] + for name, member in inspect.getmembers(obj): + if inspect.isclass(member) and issubclass(member, base_class) and member is not base_class: + subclasses.append(member) + return subclasses + + +def _exclude_key(original_dict, key_to_exclude): + """ + Returns a new dictionary excluding the specified key from the original dictionary. + + Parameters: + original_dict : dict + The dictionary to exclude the key from. + key_to_exclude : hashable + The key to exclude. + + Returns: + dict + A new dictionary without the specified key. + + Example: + >>> original = {'a': 1, 'b': 2, 'c': 3} + >>> _exclude_key(original, 'b') + {'a': 1, 'c': 3} + """ + return {key: value for key, value in original_dict.items() if key != key_to_exclude} + + +def extract_params_url(parsed_url): + + params = parse_qs(parsed_url.query) + flat_params = {k: v[0] for k, v in params.items()} + + for p, p_value in flat_params.items(): + yield "GET", parsed_url, p, p_value, "direct_url", _exclude_key(flat_params, p) + + +def extract_params_location(location_header_value, original_parsed_url): + """ + Extracts parameters from a location header, yielding them one at a time. + + Args: + location_header_value (dict): Contents of location header + original_url: The original parsed URL the header was received from (urllib.parse.ParseResult) + + Yields: + method(str), parsed_url(urllib.parse.ParseResult), parameter_name(str), original_value(str), regex_name(str), additional_params(dict): The HTTP method associated with the parameter (GET, POST, None), A urllib.parse.ParseResult object representing the endpoint associated with the parameter, the parameter found in the location header, its original value (if available), the name of the detecting regex, a dict of additional params if any + """ + if location_header_value.startswith("http://") or location_header_value.startswith("https://"): + parsed_url = urlparse(location_header_value) + else: + parsed_url = urlparse(f"{original_parsed_url.scheme}://{original_parsed_url.netloc}{location_header_value}") + + params = parse_qs(parsed_url.query) + flat_params = {k: v[0] for k, v in params.items()} + + for p, p_value in flat_params.items(): + yield "GET", parsed_url, p, p_value, "location_header", _exclude_key(flat_params, p) + + +class YaraRuleSettings: + def __init__(self, description, tags, emit_match): + self.description = description + self.tags = tags + self.emit_match = emit_match -class BaseExtractor: - # If using capture groups, be sure to name them beginning with "capture". - regexes = {} + +class ExcavateRule: + """ + The BBOT Regex Commandments: + + 1) Thou shalt employ YARA regexes in place of Python regexes, save when necessity doth compel otherwise. + 2) Thou shalt ne'er wield a Python regex against a vast expanse of text. + 3) Whensoever it be possible, thou shalt favor string matching o'er regexes. + + Amen. + """ + + yara_rules = {} def __init__(self, excavate): self.excavate = excavate - self.compiled_regexes = {} - for rname, r in self.regexes.items(): - self.compiled_regexes[rname] = re.compile(r) + self.helpers = excavate.helpers + self.name = "" + + async def preprocess(self, r, event, discovery_context): + """ + Preprocesses YARA rule results, extracts meta tags, and configures a YaraRuleSettings object. + + This method retrieves optional meta tags from YARA rules and uses them to configure a YaraRuleSettings object. + It formats the results from the YARA engine into a suitable format for the process() method and initiates + a call to process(), passing on the pre-processed YARA results, event data, YARA rule settings, and discovery context. + + This should typically NOT be overridden. + + Parameters: + r : YaraMatch + The YARA match object containing the rule and meta information. + event : Event + The event data associated with the YARA match. + discovery_context : DiscoveryContext + The context in which the discovery is made. + + Returns: + None + """ + description = "" + tags = [] + emit_match = False + + if "description" in r.meta.keys(): + description = r.meta["description"] + if "tags" in r.meta.keys(): + tags = self.excavate.helpers.chain_lists(r.meta["tags"]) + if "emit_match" in r.meta.keys(): + emit_match = True + + yara_rule_settings = YaraRuleSettings(description, tags, emit_match) + yara_results = {} + for h in r.strings: + yara_results[h.identifier.lstrip("$")] = sorted(set([i.matched_data.decode("utf-8") for i in h.instances])) + await self.process(yara_results, event, yara_rule_settings, discovery_context) - async def search(self, content, event, **kwargs): - results = set() - async for result, name in self._search(content, event, **kwargs): - results.add((result, name)) - for result, name in results: - await self.report(result, name, event, **kwargs) + async def process(self, yara_results, event, yara_rule_settings, discovery_context): + """ + Processes YARA rule results and reports events with enriched data. - async def _search(self, content, event, **kwargs): - for name, regex in self.compiled_regexes.items(): - # yield to event loop - await self.excavate.helpers.sleep(0) - for result in regex.findall(content): - yield result, name + This method iterates over the provided YARA rule results and constructs event data for each match. + It enriches the event data with host, URL, and description information, and conditionally includes + matched data based on the YaraRuleSettings. Finally, it reports the constructed event data. - async def report(self, result, name, event): - pass + Override when custom processing and/or validation is needed on data before reporting. + Parameters: + yara_results : dict + A dictionary where keys are YARA rule identifiers and values are lists of matched data strings. + 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. + discovery_context : DiscoveryContext + The context in which the discovery is made. -class CSPExtractor(BaseExtractor): - regexes = {"CSP": r"(?i)(?m)Content-Security-Policy:.+$"} + Returns: + None + """ + for identifier, results in yara_results.items(): + for result in results: + event_data = {"host": str(event.host), "url": event.data.get("url", "")} + event_data["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) - def extract_domains(self, csp): - domains = dns_name_regex.findall(csp) - unique_domains = set(domains) - return unique_domains + async def report_prep(self, event_data, event_type, event, tags): + """ + Prepares an event draft for reporting by creating and tagging the event. - async def search(self, content, event, **kwargs): - async for csp, name in self._search(content, event, **kwargs): - extracted_domains = self.extract_domains(csp) - for domain in extracted_domains: - await self.report(domain, event, **kwargs) + This method creates an event draft using the provided event data and type, associating it with a parent event. + It tags the event draft with the provided tags and returns the draft. If event creation fails, it returns None. - async def report(self, domain, event, **kwargs): - await self.excavate.emit_event(domain, "DNS_NAME", source=event, tags=["affiliate"]) + Override when an event needs to be modified before it is emitted - for example, custom tags need to be conditionally added. + Parameters: + event_data : dict + The data to be included in the event. + event_type : str + The type of the event being reported. + event : Event + The parent event to which this event draft is related. + tags : list + A list of tags to be associated with the event draft. -class HostnameExtractor(BaseExtractor): - regexes = {} + Returns: + EventDraft or None + """ + event_draft = self.excavate.make_event(event_data, event_type, parent=event) + if not event_draft: + return None + event_draft.tags = tags + return event_draft + + async def report( + self, event_data, event, yara_rule_settings, discovery_context, event_type="FINDING", abort_if=None, **kwargs + ): + """ + Reports an event by preparing an event draft and emitting it. + + Processes the provided event data, sets a default description if needed, prepares the event draft, and emits it. + It constructs a context string for the event and uses the report_prep method to create the event draft. If the draft is successfully + created, it emits the event. + + Typically not overridden, but might need to be if custom logic is needed to build description/context, etc. + + Parameters: + event_data : dict + The data to be included in the event. + 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. + discovery_context : DiscoveryContext + The context in which the discovery is made. + event_type : str, optional + The type of the event being reported, default is "FINDING". + abort_if : callable, optional + A callable that determines if the event emission should be aborted. + **kwargs : dict + Additional keyword arguments to pass to the report_prep method. + + 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']}" + subject = "" + if isinstance(event_data, str): + subject = f" event_data" + context = f"Excavate's [{self.__class__.__name__}] submodule emitted [{event_type}]{subject}, because {discovery_context} {yara_rule_settings.description}" + tags = yara_rule_settings.tags + event_draft = await self.report_prep(event_data, event_type, event, tags, **kwargs) + if event_draft: + await self.excavate.emit_event(event_draft, context=context, abort_if=abort_if) + + +class CustomExtractor(ExcavateRule): def __init__(self, excavate): - for i, r in enumerate(excavate.scan.dns_regexes): - self.regexes[f"dns_name_{i+1}"] = r.pattern super().__init__(excavate) - async def report(self, result, name, event, **kwargs): - await self.excavate.emit_event(result, "DNS_NAME", source=event) + 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 = {"host": str(event.host), "url": event.data.get("url", "")} + description_string = ( + f" with description: [{yara_rule_settings.description}]" if yara_rule_settings.description else "" + ) + event_data["description"] = ( + f"Custom Yara Rule [{self.name}]{description_string} Matched via identifier [{identifier}]" + ) + if yara_rule_settings.emit_match: + event_data["description"] += f" and extracted [{result}]" + await self.report(event_data, event, yara_rule_settings, discovery_context) -class URLExtractor(BaseExtractor): - url_path_regex = r"((?:\w|\d)(?:[\d\w-]+\.?)+(?::\d{1,5})?(?:/[-\w\.\(\)]*[-\w\.]+)*/?)" - regexes = { - "fulluri": r"(?i)" + r"([a-z]\w{1,15})://" + url_path_regex, - "fullurl": r"(?i)" + r"(https?)://" + url_path_regex, - "a-tag": r"]*?\s+)?href=([\"'])(.*?)\1", - "link-tag": r"]*?\s+)?href=([\"'])(.*?)\1", - "script-tag": r"]*?\s+)?src=([\"'])(.*?)\1", +class excavate(BaseInternalModule): + """ + Example (simple) Excavate Rules: + + class excavateTestRule(ExcavateRule): + yara_rules = { + "SearchForText": 'rule SearchForText { meta: description = "Contains the text AAAABBBBCCCC" strings: $text = "AAAABBBBCCCC" condition: $text }', + "SearchForText2": 'rule SearchForText2 { meta: description = "Contains the text DDDDEEEEFFFF" strings: $text2 = "DDDDEEEEFFFF" condition: $text2 }', + } + """ + + watched_events = ["HTTP_RESPONSE"] + produced_events = ["URL_UNVERIFIED", "WEB_PARAMETER"] + flags = ["passive"] + meta = { + "description": "Passively extract juicy tidbits from scan data", + "created_date": "2022-06-27", + "author": "@liquidsec", } - prefix_blacklist = ["javascript:", "mailto:", "tel:", "data:", "vbscript:", "about:", "file:"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.web_spider_links_per_page = self.excavate.scan.config.get("web_spider_links_per_page", 20) - - async def search(self, content, event, **kwargs): - consider_spider_danger = kwargs.get("consider_spider_danger", True) - web_spider_distance = getattr(event, "web_spider_distance", 0) - - result_hashes = set() - results = [] - async for result in self._search(content, event, **kwargs): - result_hash = hash(result[0]) - if result_hash not in result_hashes: - result_hashes.add(result_hash) - results.append(result) - - urls_found = 0 - for result, name in results: - url_event = await self.report(result, name, event, **kwargs) - if url_event is not None: - url_in_scope = self.excavate.scan.in_scope(url_event) - is_spider_danger = self.excavate.helpers.is_spider_danger(event, result) - exceeds_max_links = urls_found >= self.web_spider_links_per_page and url_in_scope - exceeds_redirect_distance = (not consider_spider_danger) and ( - web_spider_distance > self.excavate.max_redirects - ) - if is_spider_danger or exceeds_max_links or exceeds_redirect_distance: - reason = "its spider depth or distance exceeds the scan's limits" - if exceeds_max_links: - reason = f"it exceeds the max number of links per page ({self.web_spider_links_per_page})" - elif exceeds_redirect_distance: - reason = ( - f"its spider distance exceeds the max number of redirects ({self.excavate.max_redirects})" + options = { + "retain_querystring": False, + "yara_max_match_data": 2000, + "custom_yara_rules": "", + } + options_desc = { + "retain_querystring": "Keep the querystring intact on emitted WEB_PARAMETERS", + "yara_max_match_data": "Sets the maximum amount of text that can extracted from a YARA regex", + "custom_yara_rules": "Include custom Yara rules", + } + scope_distance_modifier = None + + _max_event_handlers = 8 + + parameter_blacklist = [ + "__VIEWSTATE", + "__EVENTARGUMENT", + "__EVENTVALIDATION", + "__EVENTTARGET", + "__EVENTARGUMENT", + "__VIEWSTATEGENERATOR", + "__SCROLLPOSITIONY", + "__SCROLLPOSITIONX", + "ASP.NET_SessionId", + "JSESSIONID", + "PHPSESSID", + ] + + yara_rule_name_regex = re.compile(r"rule\s(\w+)\s{") + yara_rule_regex = re.compile(r"(?s)((?:rule\s+\w+\s*{[^{}]*(?:{[^{}]*}[^{}]*)*[^{}]*(?:/\S*?}[^/]*?/)*)*})") + + def in_bl(self, value): + in_bl = False + for bl_param in self.parameter_blacklist: + if bl_param.lower() == value.lower(): + in_bl = True + return in_bl + + def url_unparse(self, param_type, parsed_url): + if param_type == "GETPARAM": + querystring = "" + else: + querystring = parsed_url.query + + return urlunparse( + ( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + "", + querystring if self.retain_querystring else "", + "", + ) + ) + + class ParameterExtractor(ExcavateRule): + + yara_rules = {} + + class ParameterExtractorRule: + name = "" + + def extract(self): + pass + + def __init__(self, excavate, result): + self.excavate = excavate + self.result = result + + class GetJquery(ParameterExtractorRule): + + name = "GET jquery" + discovery_regex = r"/\$.get\([^\)].+\)/ nocase" + extraction_regex = re.compile(r"\$.get\([\'\"](.+)[\'\"].+(\{.+\})\)") + output_type = "GETPARAM" + + def convert_to_dict(self, extracted_str): + extracted_str = extracted_str.replace("'", '"') + extracted_str = re.sub(r"(\w+):", r'"\1":', extracted_str) + try: + return json.loads(extracted_str) + except json.JSONDecodeError as e: + self.excavate.debug(f"Failed to decode JSON: {e}") + return None + + def extract(self): + extracted_results = self.extraction_regex.findall(str(self.result)) + if extracted_results: + for action, extracted_parameters in extracted_results: + extracted_parameters_dict = self.convert_to_dict(extracted_parameters) + for parameter_name, original_value in extracted_parameters_dict.items(): + yield self.output_type, parameter_name, original_value, action, _exclude_key( + extracted_parameters_dict, parameter_name + ) + + class PostJquery(GetJquery): + name = "POST jquery" + discovery_regex = r"/\$.post\([^\)].+\)/ nocase" + extraction_regex = re.compile(r"\$.post\([\'\"](.+)[\'\"].+(\{.+\})\)") + output_type = "POSTPARAM" + + class HtmlTags(ParameterExtractorRule): + name = "HTML Tags" + discovery_regex = r'/<[^>]+(href|src)=["\'][^"\']*["\'][^>]*>/ nocase' + extraction_regex = bbot_regexes.tag_attribute_regex + output_type = "GETPARAM" + + def extract(self): + urls = self.extraction_regex.findall(str(self.result)) + for url in urls: + parsed_url = urlparse(url) + query_strings = parse_qs(parsed_url.query) + query_strings_dict = { + k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in query_strings.items() + } + for parameter_name, original_value in query_strings_dict.items(): + yield self.output_type, parameter_name, original_value, url, _exclude_key( + query_strings_dict, parameter_name ) - self.excavate.debug(f"Tagging {url_event} as spider-danger because {reason}") - url_event.add_tag("spider-danger") - if "url" in event.data: - message = f"Found URL [{result}] from parsing [{event.data.get('url')}] with regex [{name}]" - else: - message = ( - f"Found URL [{result}] from parsing [{event.source.data.get('path')}] with regex [{name}]" + + class GetForm(ParameterExtractorRule): + name = "GET Form" + discovery_regex = r'/]*\bmethod=["\']?get["\']?[^>]*>.*<\/form>/s nocase' + form_content_regexes = [ + bbot_regexes.input_tag_regex, + bbot_regexes.select_tag_regex, + bbot_regexes.textarea_tag_regex, + ] + extraction_regex = bbot_regexes.get_form_regex + output_type = "GETPARAM" + + def extract(self): + forms = self.extraction_regex.findall(str(self.result)) + for form_action, form_content in forms: + form_parameters = {} + for form_content_regex in self.form_content_regexes: + input_tags = form_content_regex.findall(form_content) + + for parameter_name, original_value in input_tags: + form_parameters[parameter_name] = original_value + + for parameter_name, original_value in form_parameters.items(): + yield self.output_type, parameter_name, original_value, form_action, _exclude_key( + form_parameters, parameter_name + ) + + class PostForm(GetForm): + name = "POST Form" + discovery_regex = r'/]*\bmethod=["\']?post["\']?[^>]*>.*<\/form>/s nocase' + extraction_regex = bbot_regexes.post_form_regex + output_type = "POSTPARAM" + + def __init__(self, excavate): + super().__init__(excavate) + self.parameterExtractorCallbackDict = {} + regexes_component_list = [] + parameterExtractorRules = find_subclasses(self, self.ParameterExtractorRule) + for r in parameterExtractorRules: + self.excavate.verbose(f"Including ParameterExtractor Submodule: {r.__name__}") + self.parameterExtractorCallbackDict[r.__name__] = r + regexes_component_list.append(f"${r.__name__} = {r.discovery_regex}") + regexes_component = " ".join(regexes_component_list) + self.yara_rules[f"parameter_extraction"] = ( + rf'rule parameter_extraction {{meta: description = "contains POST form" strings: {regexes_component} condition: any of them}}' + ) + + async def process(self, yara_results, event, yara_rule_settings, discovery_context): + for identifier, results in yara_results.items(): + for result in results: + if identifier not in self.parameterExtractorCallbackDict.keys(): + raise ExcavateError("ParameterExtractor YaraRule identified reference non-existent submodule") + parameterExtractorSubModule = self.parameterExtractorCallbackDict[identifier]( + self.excavate, result ) - self.excavate.debug(message) - await self.excavate.emit_event(url_event) - if url_in_scope: - urls_found += 1 - - async def _search(self, content, event, **kwargs): - parsed = getattr(event, "parsed", None) - for name, regex in self.compiled_regexes.items(): - # yield to event loop - await self.excavate.helpers.sleep(0) - for result in regex.findall(content): - if name.startswith("full"): - protocol, other = result - result = f"{protocol}://{other}" - - elif parsed and name.endswith("-tag"): - path = html.unescape(result[1]) - - for p in self.prefix_blacklist: - if path.lower().startswith(p.lower()): + extracted_params = parameterExtractorSubModule.extract() + if extracted_params: + for ( + parameter_type, + parameter_name, + original_value, + endpoint, + additional_params, + ) in extracted_params: + self.excavate.debug( - f"omitted result from a-tag parser because of blacklisted prefix [{p}]" + f"Found Parameter [{parameter_name}] in [{parameterExtractorSubModule.name}] ParameterExtractor Submodule" + ) + endpoint = event.data["url"] if not endpoint else endpoint + url = ( + endpoint + if endpoint.startswith(("http://", "https://")) + else f"{event.parsed_url.scheme}://{event.parsed_url.netloc}{endpoint}" ) - continue - if not self.compiled_regexes["fullurl"].match(path): - source_url = event.parsed.geturl() - result = urljoin(source_url, path) - # this is necessary to weed out mailto: and such - if not self.compiled_regexes["fullurl"].match(result): - continue - else: - result = path + if self.excavate.helpers.validate_parameter(parameter_name, parameter_type): - yield result, name + if self.excavate.in_bl(parameter_name) == False: + parsed_url = urlparse(url) + description = f"HTTP Extracted Parameter [{parameter_name}] ({parameterExtractorSubModule.name} Submodule)" + data = { + "host": parsed_url.hostname, + "type": parameter_type, + "name": parameter_name, + "original_value": original_value, + "url": self.excavate.url_unparse(parameter_type, parsed_url), + "additional_params": additional_params, + "assigned_cookies": self.excavate.assigned_cookies, + "description": description, + } + await self.report( + data, event, yara_rule_settings, discovery_context, event_type="WEB_PARAMETER" + ) + else: + self.excavate.debug(f"blocked parameter [{parameter_name}] due to BL match") + else: + self.excavate.debug(f"blocked parameter [{parameter_name}] due to validation failure") - async def report(self, result, name, event, **kwargs): - parsed_uri = None - try: - parsed_uri = self.excavate.helpers.urlparse(result) - except Exception as e: - self.excavate.debug(f"Error parsing URI {result}: {e}") - netloc = getattr(parsed_uri, "netloc", None) - if netloc is None: - return - host, port = self.excavate.helpers.split_host_port(parsed_uri.netloc) - # Handle non-HTTP URIs (ftp, s3, etc.) - if not "http" in parsed_uri.scheme.lower(): - # these findings are pretty mundane so don't bother with them if they aren't in scope - abort_if = lambda e: e.scope_distance > 0 - event_data = {"host": str(host), "description": f"Non-HTTP URI: {result}"} - parsed_url = getattr(event, "parsed", None) - if parsed_url: - event_data["url"] = parsed_url.geturl() - await self.excavate.emit_event( - event_data, - "FINDING", - source=event, - abort_if=abort_if, - ) - protocol_data = {"protocol": parsed_uri.scheme, "host": str(host)} - if port: - protocol_data["port"] = port - await self.excavate.emit_event( - protocol_data, - "PROTOCOL", - source=event, - abort_if=abort_if, - ) - return + class CSPExtractor(ExcavateRule): + yara_rules = { + "csp": r'rule csp { meta: tags = "affiliate" description = "contains CSP Header" strings: $csp = /Content-Security-Policy:[^\r\n]+/ nocase condition: $csp }', + } - return self.excavate.make_event(result, "URL_UNVERIFIED", source=event) + async def process(self, yara_results, event, yara_rule_settings, discovery_context): + for identifier in yara_results.keys(): + for csp_str in yara_results[identifier]: + domains = await self.helpers.re.findall(bbot_regexes.dns_name_regex, csp_str) + unique_domains = set(domains) + for domain in unique_domains: + await self.report(domain, event, yara_rule_settings, discovery_context, event_type="DNS_NAME") + class EmailExtractor(ExcavateRule): -class EmailExtractor(BaseExtractor): - regexes = {"email": _email_regex} - tld_blacklist = ["png", "jpg", "jpeg", "bmp", "ico", "gif", "svg", "css", "ttf", "woff", "woff2"] + yara_rules = { + "email": 'rule email { meta: description = "contains email address" strings: $email = /[^\\W_][\\w\\-\\.\\+\']{0,100}@[a-zA-Z0-9\\-]{1,100}(\\.[a-zA-Z0-9\\-]{1,100})*\\.[a-zA-Z]{2,63}/ nocase fullword condition: $email }', + } - async def report(self, result, name, event, **kwargs): - result = result.lower() - tld = result.split(".")[-1] - if tld not in self.tld_blacklist: - if "url" in event.data: - message = f"Found email address [{result}] from parsing [{event.data.get('url')}]" - else: - message = f"Found email address [{result}] from parsing [{event.source.data.get('path')}]" - self.excavate.debug(message) - await self.excavate.emit_event(result, "EMAIL_ADDRESS", source=event) - - -class ErrorExtractor(BaseExtractor): - regexes = { - "PHP:1": r"\.php on line [0-9]+", - "PHP:2": r"\.php on line [0-9]+", - "PHP:3": "Fatal error:", - "Microsoft SQL Server:1": r"\[(ODBC SQL Server Driver|SQL Server|ODBC Driver Manager)\]", - "Microsoft SQL Server:2": "You have an error in your SQL syntax; check the manual", - "Java:1": r"\.java:[0-9]+", - "Java:2": r"\.java\((Inlined )?Compiled Code\)", - "Perl": r"at (\/[A-Za-z0-9\.]+)*\.pm line [0-9]+", - "Python": r"File \"[A-Za-z0-9\-_\./]*\", line [0-9]+, in", - "Ruby": r"\.rb:[0-9]+:in", - "ASP.NET:1": "Exception of type", - "ASP.NET:2": "--- End of inner exception stack trace ---", - "ASP.NET:3": "Microsoft OLE DB Provider", - "ASP.NET:4": r"Error ([\d-]+) \([\dA-F]+\)", - } + async def process(self, yara_results, event, yara_rule_settings, discovery_context): + for identifier in yara_results.keys(): + for email_str in yara_results[identifier]: + await self.report( + email_str, event, yara_rule_settings, discovery_context, event_type="EMAIL_ADDRESS" + ) - async def report(self, result, name, event, **kwargs): - self.excavate.debug(f"Found error message from parsing [{event.data.get('url')}] with regex [{name}]") - description = f"Error message Detected at Error Type: {name}" - await self.excavate.emit_event( - {"host": str(event.host), "url": event.data.get("url", ""), "description": description}, - "FINDING", - source=event, - ) + # Future Work: Emit a JWT Object, and make a new Module to ingest it. + class JWTExtractor(ExcavateRule): + yara_rules = { + "jwt": r'rule jwt { meta: emit_match = "True" description = "contains JSON Web Token (JWT)" strings: $jwt = /\beyJ[_a-zA-Z0-9\/+]*\.[_a-zA-Z0-9\/+]*\.[_a-zA-Z0-9\/+]*/ nocase condition: $jwt }', + } + class ErrorExtractor(ExcavateRule): -class JWTExtractor(BaseExtractor): - regexes = {"JWT": r"eyJ(?:[\w-]*\.)(?:[\w-]*\.)[\w-]*"} + signatures = { + "PHP_1": r"/\.php on line [0-9]+/", + "PHP_2": r"/\.php<\/b> on line [0-9]+/", + "PHP_3": '"Fatal error:"', + "Microsoft_SQL_Server_1": r"/\[(ODBC SQL Server Driver|SQL Server|ODBC Driver Manager)\]/", + "Microsoft_SQL_Server_2": '"You have an error in your SQL syntax; check the manual"', + "Java_1": r"/\.java:[0-9]+/", + "Java_2": r"/\.java\((Inlined )?Compiled Code\)/", + "Perl": r"/at (\/[A-Za-z0-9\._]+)*\.pm line [0-9]+/", + "Python": r"/File \"[A-Za-z0-9\-_\.\/]*\", line [0-9]+, in/", + "Ruby": r"/\.rb:[0-9]+:in/", + "ASPNET_1": '"Exception of type"', + "ASPNET_2": '"--- End of inner exception stack trace ---"', + "ASPNET_3": '"Microsoft OLE DB Provider"', + "ASPNET_4": r"/Error ([\d-]+) \([\dA-F]+\)/", + } + yara_rules = {} - async def report(self, result, name, event, **kwargs): - self.excavate.debug(f"Found JWT candidate [{result}]") - try: - j.decode(result, options={"verify_signature": False}) - jwt_headers = j.get_unverified_header(result) - tags = [] - if jwt_headers["alg"].upper()[0:2] == "HS": - tags = ["crackable"] - description = f"JWT Identified [{result}]" - await self.excavate.emit_event( - {"host": str(event.host), "url": event.data.get("url", ""), "description": description}, - "FINDING", - event, - tags=tags, + def __init__(self, excavate): + super().__init__(excavate) + signature_component_list = [] + for signature_name, signature in self.signatures.items(): + signature_component_list.append(rf"${signature_name} = {signature}") + signature_component = " ".join(signature_component_list) + self.yara_rules[f"error_detection"] = ( + f'rule error_detection {{meta: description = "contains a verbose error message" strings: {signature_component} condition: any of them}}' ) - except j.exceptions.DecodeError: - self.excavate.debug(f"Error decoding JWT candidate {result}") + 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 = { + "host": str(event.host), + "url": event.data.get("url", ""), + "description": f"{discovery_context} {yara_rule_settings.description} ({identifier})", + } + await self.report(event_data, event, yara_rule_settings, discovery_context, event_type="FINDING") + class SerializationExtractor(ExcavateRule): -class SerializationExtractor(BaseExtractor): - regexes = { - "Java": r"(?:[^a-zA-Z0-9+/]|^)(rO0[a-zA-Z0-9+/]+={,2})", - ".NET": r"(?:[^a-zA-Z0-9+/]|^)(AAEAAAD//[a-zA-Z0-9+/]+={,2})", - "PHP (Array)": r"(?:[^a-zA-Z0-9+/]|^)(YTo[xyz0123456][a-zA-Z0-9+/]+={,2})", - "PHP (String)": r"(?:[^a-zA-Z0-9+/]|^)(czo[xyz0123456][a-zA-Z0-9+/]+={,2})", - "PHP (Object)": r"(?:[^a-zA-Z0-9+/]|^)(Tzo[xyz0123456][a-zA-Z0-9+/]+={,2})", - "Possible Compressed": r"(?:[^a-zA-Z0-9+/]|^)(H4sIAAAAAAAA[a-zA-Z0-9+/]+={,2})", - } + regexes = { + "Java": re.compile(r"[^a-zA-Z0-9\/+]rO0[a-zA-Z0-9+\/]+={0,2}"), + "DOTNET": re.compile(r"[^a-zA-Z0-9\/+]AAEAAAD\/\/[a-zA-Z0-9\/+]+={0,2}"), + "PHP_Array": re.compile(r"[^a-zA-Z0-9\/+]YTo[xyz0123456][a-zA-Z0-9+\/]+={0,2}"), + "PHP_String": re.compile(r"[^a-zA-Z0-9\/+]czo[xyz0123456][a-zA-Z0-9+\/]+={0,2}"), + "PHP_Object": re.compile(r"[^a-zA-Z0-9\/+]Tzo[xyz0123456][a-zA-Z0-9+\/]+={0,2}"), + "Possible_Compressed": re.compile(r"[^a-zA-Z0-9\/+]H4sIAAAAAAAA[a-zA-Z0-9+\/]+={0,2}"), + } + yara_rules = {} - async def report(self, result, name, event, **kwargs): - description = f"{name} serialized object found: [{self.excavate.helpers.truncate_string(result,2000)}]" - await self.excavate.emit_event( - {"host": str(event.host), "url": event.data.get("url"), "description": description}, "FINDING", event - ) + def __init__(self, excavate): + super().__init__(excavate) + regexes_component_list = [] + for regex_name, regex in self.regexes.items(): + regexes_component_list.append(rf"${regex_name} = /\b{regex.pattern}/ nocase") + regexes_component = " ".join(regexes_component_list) + self.yara_rules[f"serialization_detection"] = ( + f'rule serialization_detection {{meta: description = "contains a possible serialized object" 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 = { + "host": str(event.host), + "url": event.data.get("url", ""), + "description": f"{discovery_context} {yara_rule_settings.description} ({identifier})", + } + await self.report(event_data, event, yara_rule_settings, discovery_context, event_type="FINDING") -class FunctionalityExtractor(BaseExtractor): - regexes = { - "File Upload Functionality": r"(]+type=[\"']?file[\"']?[^>]+>)", - "Web Service WSDL": r"(?i)((?:http|https)://[^\s]*?.(?:wsdl))", - } + class FunctionalityExtractor(ExcavateRule): - async def report(self, result, name, event, **kwargs): - description = f"{name} found" - await self.excavate.emit_event( - {"host": str(event.host), "url": event.data.get("url"), "description": description}, "FINDING", event - ) + yara_rules = { + "File_Upload_Functionality": r'rule File_Upload_Functionality { meta: description = "contains file upload functionality" strings: $fileuploadfunc = /]+type=["\']?file["\']?[^>]+>/ nocase condition: $fileuploadfunc }', + "Web_Service_WSDL": r'rule Web_Service_WSDL { meta: emit_match = "True" description = "contains a web service WSDL URL" strings: $wsdl = /https?:\/\/[^\s]*\.(wsdl)/ nocase condition: $wsdl }', + } + class NonHttpSchemeExtractor(ExcavateRule): + yara_rules = { + "Non_HTTP_Scheme": r'rule Non_HTTP_Scheme { meta: description = "contains non-http scheme URL" strings: $nonhttpscheme = /\b\w{2,35}:\/\/[\w.-]+(:\d+)?\b/ nocase fullword condition: $nonhttpscheme }' + } -class JavascriptExtractor(BaseExtractor): - # based on on https://github.com/m4ll0k/SecretFinder/blob/master/SecretFinder.py - - regexes = { - "google_api": r"AIza[0-9A-Za-z-_]{35}", - "firebase": r"AAAA[A-Za-z0-9_-]{7}:[A-Za-z0-9_-]{140}", - "google_oauth": r"ya29\.[0-9A-Za-z\-_]+", - "amazon_aws_access_key_id": r"A[SK]IA[0-9A-Z]{16}", - "amazon_mws_auth_token": r"amzn\\.mws\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", - # "amazon_aws_url": r"s3\.amazonaws.com[/]+|[a-zA-Z0-9_-]*\.s3\.amazonaws.com", - # "amazon_aws_url2": r"[a-zA-Z0-9-\.\_]+\.s3\.amazonaws\.com", - # "amazon_aws_url3": r"s3://[a-zA-Z0-9-\.\_]+", - # "amazon_aws_url4": r"s3.amazonaws.com/[a-zA-Z0-9-\.\_]+", - # "amazon_aws_url5": r"s3.console.aws.amazon.com/s3/buckets/[a-zA-Z0-9-\.\_]+", - "facebook_access_token": r"EAACEdEose0cBA[0-9A-Za-z]+", - "authorization_basic": r"(?i)basic [a-zA-Z0-9:_\+\/-]{4,100}={0,2}", - "authorization_bearer": r"bearer [a-zA-Z0-9_\-\.=:_\+\/]{5,100}", - "apikey": r"api(?:key|_key)\s?=\s?[\'\"\`][a-zA-Z0-9_\-]{5,100}[\'\"\`]", - "mailgun_api_key": r"key-[0-9a-zA-Z]{32}", - "paypal_braintree_access_token": r"access_token\$production\$[0-9a-z]{16}\$[0-9a-f]{32}", - "square_oauth_secret": r"sq0csp-[ 0-9A-Za-z\-_]{43}|sq0[a-z]{3}-[0-9A-Za-z\-_]{22,43}", - "square_access_token": r"sqOatp-[0-9A-Za-z\-_]{22}", - "stripe_standard_api": r"sk_live_[0-9a-zA-Z]{24}", - "stripe_restricted_api": r"rk_live_[0-9a-zA-Z]{24}", - "github_access_token": r"[a-zA-Z0-9_-]*:[a-zA-Z0-9_\-]+@github\.com*", - "rsa_private_key": r"-----BEGIN RSA PRIVATE KEY-----", - "ssh_dsa_private_key": r"-----BEGIN DSA PRIVATE KEY-----", - "ssh_dc_private_key": r"-----BEGIN EC PRIVATE KEY-----", - "pgp_private_block": r"-----BEGIN PGP PRIVATE KEY BLOCK-----", - "json_web_token": r"ey[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$", - "slack_token": r"\"api_token\":\"(xox[a-zA-Z]-[a-zA-Z0-9-]+)\"", - "SSH_privKey": r"([-]+BEGIN [^\s]+ PRIVATE KEY[-]+[\s]*[^-]*[-]+END [^\s]+ PRIVATE KEY[-]+)", - "possible_creds_var": r"(?:password|passwd|pwd|pass)\s*=+\s*['\"][^\s'\"]{1,60}['\"]", - } + scheme_blacklist = ["javascript", "mailto", "tel", "data", "vbscript", "about", "file"] - async def report(self, result, name, event, **kwargs): - # ensure that basic auth matches aren't false positives - if name == "authorization_basic": - try: - b64test = base64.b64decode(result.split(" ", 1)[-1].encode()) - if b":" not in b64test: - return - except (base64.binascii.Error, UnicodeDecodeError): - return + async def process(self, yara_results, event, yara_rule_settings, discovery_context): + for identifier, results in yara_results.items(): + for url_str in results: + scheme = url_str.split("://")[0] + if scheme in self.scheme_blacklist: + continue + if scheme not in self.excavate.valid_schemes: + continue + try: + parsed_url = urlparse(url_str) + except Exception as e: + self.excavate.debug(f"Error parsing URI {url_str}: {e}") + continue + netloc = getattr(parsed_url, "netloc", None) + if netloc is None: + continue + try: + host, port = self.excavate.helpers.split_host_port(parsed_url.netloc) + except ValueError as e: + self.excavate.debug(f"Failed to parse netloc: {e}") + continue + if parsed_url.scheme in ["http", "https"]: + continue + abort_if = lambda e: e.scope_distance > 0 + finding_data = {"host": str(host), "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: + protocol_data["port"] = port + await self.report( + protocol_data, + event, + yara_rule_settings, + discovery_context, + event_type="PROTOCOL", + abort_if=abort_if, + ) - self.excavate.debug(f"Found Possible Secret in Javascript [{result}]") - description = f"Possible secret in JS [{result}] Signature [{name}]" - await self.excavate.emit_event( - {"host": str(event.host), "url": event.data.get("url", ""), "description": description}, "FINDING", event - ) + class URLExtractor(ExcavateRule): + yara_rules = { + "url_full": r'rule url_full { meta: tags = "spider-danger" description = "contains full URL" strings: $url_full = /https?:\/\/([\w\.-]+)([:\/\w\.-]*)/ condition: $url_full }', + "url_attr": r'rule url_attr { meta: tags = "spider-danger" description = "contains tag with src or href attribute" strings: $url_attr = /<[^>]+(href|src)=["\'][^"\']*["\'][^>]*>/ condition: $url_attr }', + } + full_url_regex = re.compile(r"(https?)://((?:\w|\d)(?:[\d\w-]+\.?)+(?::\d{1,5})?(?:/[-\w\.\(\)]*[-\w\.]+)*/?)") + full_url_regex_strict = re.compile(r"^(https?):\/\/([\w.-]+)(?::\d{1,5})?(\/[\w\/\.-]*)?(\?[^\s]+)?$") + tag_attribute_regex = bbot_regexes.tag_attribute_regex + async def process(self, yara_results, event, yara_rule_settings, discovery_context): -class excavate(BaseInternalModule): - watched_events = ["HTTP_RESPONSE", "RAW_TEXT"] - produced_events = ["URL_UNVERIFIED"] - flags = ["passive"] - meta = { - "description": "Passively extract juicy tidbits from scan data", - "created_date": "2022-06-27", - "author": "@liquidsec", - } + for identifier, results in yara_results.items(): + urls_found = 0 + for url_str in results: + if identifier == "url_full": + if not await self.helpers.re.search(self.full_url_regex, url_str): + self.excavate.debug( + f"Rejecting potential full URL [{url_str}] as did not match full_url_regex" + ) + continue + final_url = url_str - scope_distance_modifier = None + self.excavate.debug(f"Discovered Full URL [{final_url}]") + elif identifier == "url_attr": + m = await self.helpers.re.search(self.tag_attribute_regex, url_str) + if not m: + self.excavate.debug( + f"Rejecting potential attribute URL [{url_str}] as did not match tag_attribute_regex" + ) + continue + unescaped_url = html.unescape(m.group(1)) + source_url = event.parsed_url.geturl() + final_url = urljoin(source_url, unescaped_url) + if not await self.helpers.re.search(self.full_url_regex_strict, final_url): + self.excavate.debug( + f"Rejecting reconstructed URL [{final_url}] as did not match full_url_regex_strict" + ) + continue + self.excavate.debug( + f"Reconstructed Full URL [{final_url}] from extracted relative URL [{unescaped_url}] " + ) + + if self.excavate.scan.in_scope(final_url): + urls_found += 1 + + await self.report( + final_url, + event, + yara_rule_settings, + discovery_context, + event_type="URL_UNVERIFIED", + urls_found=urls_found, + ) + + async def report_prep(self, event_data, event_type, event, tags, **kwargs): + event_draft = self.excavate.make_event(event_data, event_type, parent=event) + if not event_draft: + return None + url_in_scope = self.excavate.scan.in_scope(event_draft) + urls_found = kwargs.get("urls_found", None) + if urls_found: + exceeds_max_links = urls_found > self.excavate.scan.web_spider_links_per_page and url_in_scope + if exceeds_max_links: + tags.append("spider-max") + event_draft.tags = tags + return event_draft + + class HostnameExtractor(ExcavateRule): + yara_rules = {} + + def __init__(self, excavate): + super().__init__(excavate) + regexes_component_list = [] + if excavate.scan.dns_regexes_yara: + for i, r in enumerate(excavate.scan.dns_regexes_yara): + regexes_component_list.append(rf"$dns_name_{i} = /\b{r.pattern}/ nocase") + regexes_component = " ".join(regexes_component_list) + self.yara_rules[f"hostname_extraction"] = ( + f'rule hostname_extraction {{meta: description = "matches DNS hostname pattern derived from target(s)" 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 domain_str in yara_results[identifier]: + await self.report(domain_str, event, yara_rule_settings, discovery_context, event_type="DNS_NAME") + + def add_yara_rule(self, rule_name, rule_content, rule_instance): + rule_instance.name = rule_name + self.yara_rules_dict[rule_name] = rule_content + self.yara_preprocess_dict[rule_name] = rule_instance.preprocess + + async def extract_yara_rules(self, rules_content): + for r in await self.helpers.re.findall(self.yara_rule_regex, rules_content): + yield r async def setup(self): - self.csp = CSPExtractor(self) - self.hostname = HostnameExtractor(self) - self.url = URLExtractor(self) - self.email = EmailExtractor(self) - self.error_extractor = ErrorExtractor(self) - self.jwt = JWTExtractor(self) - self.javascript = JavascriptExtractor(self) - self.serialization = SerializationExtractor(self) - self.functionality = FunctionalityExtractor(self) - max_redirects = self.scan.config.get("http_max_redirects", 5) - self.web_spider_distance = self.scan.config.get("web_spider_distance", 0) - self.max_redirects = max(max_redirects, self.web_spider_distance) + self.yara_rules_dict = {} + self.yara_preprocess_dict = {} + + modules_WEB_PARAMETER = [ + module_name + for module_name, module in self.scan.modules.items() + if "WEB_PARAMETER" in module.watched_events + ] + + self.parameter_extraction = bool(modules_WEB_PARAMETER) + + self.retain_querystring = False + if self.config.get("retain_querystring", False) == True: + self.retain_querystring = True + + for module in self.scan.modules.values(): + if not str(module).startswith("_"): + ExcavateRules = find_subclasses(module, ExcavateRule) + for e in ExcavateRules: + self.verbose(f"Including Submodule {e.__name__}") + if e.__name__ == "ParameterExtractor": + message = ( + "Parameter Extraction disabled because no modules consume WEB_PARAMETER events" + if not self.parameter_extraction + else f"Parameter Extraction enabled because the following modules consume WEB_PARAMETER events: [{', '.join(modules_WEB_PARAMETER)}]" + ) + self.debug(message) if not self.parameter_extraction else self.hugeinfo(message) + # do not add parameter extraction yara rules if it's disabled + if not self.parameter_extraction: + continue + excavateRule = e(self) + for rule_name, rule_content in excavateRule.yara_rules.items(): + self.add_yara_rule(rule_name, rule_content, excavateRule) + + self.custom_yara_rules = str(self.config.get("custom_yara_rules", "")) + if self.custom_yara_rules: + custom_rules_count = 0 + if Path(self.custom_yara_rules).is_file(): + with open(self.custom_yara_rules) as f: + rules_content = f.read() + self.debug(f"Successfully loaded secrets file [{self.custom_yara_rules}]") + else: + self.debug(f"Custom secrets is NOT a file. Will attempt to treat it as rule content") + rules_content = self.custom_yara_rules + + self.debug(f"Final combined yara rule contents: {rules_content}") + custom_yara_rule_processed = self.extract_yara_rules(rules_content) + async for rule_content in custom_yara_rule_processed: + try: + yara.compile(source=rule_content) + except yara.SyntaxError as e: + self.hugewarning(f"Custom Yara rule failed to compile: {e}") + return False + + rule_match = await self.helpers.re.search(self.yara_rule_name_regex, rule_content) + if not rule_match: + self.hugewarning(f"Custom Yara formatted incorrectly: could not find rule name") + return False + + rule_name = rule_match.groups(1)[0] + c = CustomExtractor(self) + self.add_yara_rule(rule_name, rule_content, c) + custom_rules_count += 1 + if custom_rules_count > 0: + self.hugeinfo(f"Successfully added {str(custom_rules_count)} custom Yara rule(s)") + + yara_max_match_data = self.config.get("yara_max_match_data", 2000) + + yara.set_config(max_match_data=yara_max_match_data) + yara_rules_combined = "\n".join(self.yara_rules_dict.values()) + try: + self.yara_rules = yara.compile(source=yara_rules_combined) + except yara.SyntaxError as e: + self.hugewarning(f"Yara Rules failed to compile with error: [{e}]") + self.debug(yara_rules_combined) + return False + + # pre-load valid URL schemes + valid_schemes_filename = self.helpers.wordlist_dir / "valid_url_schemes.txt" + self.valid_schemes = set(self.helpers.read_file(valid_schemes_filename)) + + self.url_querystring_remove = self.scan.config.get("url_querystring_remove", True) + return True - async def search(self, source, extractors, event, **kwargs): - for e in extractors: - await e.search(source, event, **kwargs) + async def search(self, data, event, content_type, discovery_context="HTTP response"): + if not data: + return None + + decoded_data = await self.helpers.re.recursive_decode(data) + + content_type_lower = content_type.lower() if content_type else "" + extraction_map = { + "json": self.helpers.extract_params_json, + "xml": self.helpers.extract_params_xml, + } + + for source_type, extract_func in extraction_map.items(): + if source_type in content_type_lower: + results = extract_func(data) + if results: + for parameter_name, original_value in results: + description = ( + f"HTTP Extracted Parameter (speculative from {source_type} content) [{parameter_name}]" + ) + data = { + "host": str(event.host), + "type": "SPECULATIVE", + "name": parameter_name, + "original_value": original_value, + "url": str(event.data["url"]), + "additional_params": {}, + "assigned_cookies": self.assigned_cookies, + "description": description, + } + context = f"excavate's Parameter extractor found a speculative WEB_PARAMETER: {parameter_name} by parsing {source_type} data from {str(event.host)}" + await self.emit_event(data, "WEB_PARAMETER", event, context=context) + return + + for result in self.yara_rules.match(data=f"{data}\n{decoded_data}"): + rule_name = result.rule + if rule_name in self.yara_preprocess_dict: + await self.yara_preprocess_dict[rule_name](result, event, discovery_context) + else: + self.hugewarning(f"YARA Rule {rule_name} not found in pre-compiled rules") async def handle_event(self, event): + # Harvest GET parameters from URL, if it came directly from the target, and parameter extraction is enabled + if ( + self.parameter_extraction == True + and self.url_querystring_remove == False + and str(event.parent.parent.module) == "TARGET" + ): + self.debug(f"Processing target URL [{urlunparse(event.parsed_url)}] for GET parameters") + for ( + method, + parsed_url, + parameter_name, + original_value, + regex_name, + additional_params, + ) in extract_params_url(event.parsed_url): + if self.in_bl(parameter_name) == False: + description = f"HTTP Extracted Parameter [{parameter_name}] (Target URL)" + data = { + "host": parsed_url.hostname, + "type": "GETPARAM", + "name": parameter_name, + "original_value": original_value, + "url": self.url_unparse("GETPARAM", parsed_url), + "description": description, + "additional_params": additional_params, + } + context = f"Excavate parsed a URL directly from the scan target for parameters and found [GETPARAM] Parameter Name: [{parameter_name}] and emitted a WEB_PARAMETER for it" + await self.emit_event(data, "WEB_PARAMETER", event, context=context) + data = event.data - # HTTP_RESPONSE is a special case - if event.type == "HTTP_RESPONSE": - # handle redirects - web_spider_distance = getattr(event, "web_spider_distance", 0) - num_redirects = max(getattr(event, "num_redirects", 0), web_spider_distance) - location = getattr(event, "redirect_location", "") - # if it's a redirect - if location: - # get the url scheme - scheme = self.helpers.is_uri(location, return_scheme=True) - if scheme in ("http", "https"): - if num_redirects <= self.max_redirects: - # tag redirects to out-of-scope hosts as affiliates - url_event = self.make_event(location, "URL_UNVERIFIED", event, tags="affiliate") - if url_event is not None: - # inherit web spider distance from parent (don't increment) - source_web_spider_distance = getattr(event, "web_spider_distance", 0) - url_event.web_spider_distance = source_web_spider_distance - await self.emit_event(url_event) + # process response data + body = event.data.get("body", "") + headers = event.data.get("header-dict", {}) + if body == "" and headers == {}: + return + + self.assigned_cookies = {} + content_type = None + reported_location_header = False + + for header, header_values in headers.items(): + for header_value in header_values: + if header.lower() == "set-cookie": + if "=" not in header_value: + self.debug(f"Cookie found without '=': {header_value}") + continue else: - self.verbose(f"Exceeded max HTTP redirects ({self.max_redirects}): {location}") - - body = self.helpers.recursive_decode(event.data.get("body", "")) - # Cloud extractors - self.helpers.cloud.excavate(event, body) - await self.search( - body, - [ - self.hostname, - self.url, - self.email, - self.error_extractor, - self.jwt, - self.javascript, - self.serialization, - self.functionality, - ], - event, - consider_spider_danger=True, - ) + cookie_name = header_value.split("=")[0] + cookie_value = header_value.split("=")[1].split(";")[0] - headers = self.helpers.recursive_decode(event.data.get("raw_header", "")) - await self.search( - headers, - [self.hostname, self.url, self.email, self.error_extractor, self.jwt, self.serialization, self.csp], - event, - consider_spider_danger=False, - ) + if self.in_bl(cookie_value) == False: + self.assigned_cookies[cookie_name] = cookie_value + description = f"Set-Cookie Assigned Cookie [{cookie_name}]" + data = { + "host": str(event.host), + "type": "COOKIE", + "name": cookie_name, + "original_value": cookie_value, + "url": self.url_unparse("COOKIE", event.parsed_url), + "description": description, + } + context = f"Excavate noticed a set-cookie header for cookie [{cookie_name}] and emitted a WEB_PARAMETER for it" + await self.emit_event(data, "WEB_PARAMETER", event, context=context) + else: + self.debug(f"blocked cookie parameter [{cookie_name}] due to BL match") + if header.lower() == "location": + redirect_location = getattr(event, "redirect_location", "") + if redirect_location: + scheme = self.helpers.is_uri(redirect_location, return_scheme=True) + if scheme in ("http", "https"): + web_spider_distance = getattr(event, "web_spider_distance", 0) + num_redirects = max(getattr(event, "num_redirects", 0), web_spider_distance) + if num_redirects <= self.scan.web_max_redirects: + # we do not want to allow the web_spider_distance to be incremented on redirects, so we do not add spider-danger tag + url_event = self.make_event( + redirect_location, "URL_UNVERIFIED", event, tags="affiliate" + ) + if url_event is not None: + reported_location_header = True + await self.emit_event( + url_event, + context=f'evcavate looked in "Location" header and found {url_event.type}: {url_event.data}', + ) - else: - await self.search( - str(data), - [self.hostname, self.url, self.email, self.error_extractor, self.jwt, self.serialization], - event, - ) + # Try to extract parameters from the redirect URL + if self.parameter_extraction: + + for ( + method, + parsed_url, + parameter_name, + original_value, + regex_name, + additional_params, + ) in extract_params_location(header_value, event.parsed_url): + if self.in_bl(parameter_name) == False: + description = f"HTTP Extracted Parameter [{parameter_name}] (Location Header)" + data = { + "host": parsed_url.hostname, + "type": "GETPARAM", + "name": parameter_name, + "original_value": original_value, + "url": self.url_unparse("GETPARAM", parsed_url), + "description": description, + "additional_params": additional_params, + } + context = f"Excavate parsed a location header for parameters and found [GETPARAM] Parameter Name: [{parameter_name}] and emitted a WEB_PARAMETER for it" + await self.emit_event(data, "WEB_PARAMETER", event, context=context) + else: + self.warning("location header found but missing redirect_location in HTTP_RESPONSE") + if header.lower() == "content-type": + content_type = headers["content-type"][0] + + await self.search( + body, + event, + content_type, + discovery_context="HTTP response (body)", + ) + + if reported_location_header: + # Location header should be removed if we already found and emitted a result. + # Failure to do so results in a race against the same URL extracted by the URLExtractor submodule + # If the extracted URL wins, it will cause the manual one to be a dupe, but it will have a higher web_spider_distance. + headers.pop("location") + headers_str = "\n".join(f"{k}: {v}" for k, values in headers.items() for v in values) + + await self.search( + headers_str, + event, + content_type, + discovery_context="HTTP response (headers)", + ) diff --git a/bbot/modules/internal/speculate.py b/bbot/modules/internal/speculate.py index 2d7ea7162..1578a08c9 100644 --- a/bbot/modules/internal/speculate.py +++ b/bbot/modules/internal/speculate.py @@ -40,6 +40,8 @@ class speculate(BaseInternalModule): scope_distance_modifier = 1 _priority = 4 + default_discovery_context = "speculated {event.type}: {event.data}" + async def setup(self): scan_modules = [m for m in self.scan.modules.values() if m._type == "scan"] self.open_port_consumers = any(["OPEN_TCP_PORT" in m.watched_events for m in scan_modules]) @@ -49,7 +51,7 @@ async def setup(self): ) self.emit_open_ports = self.open_port_consumers and not self.portscanner_enabled self.range_to_ip = True - self.dns_resolution = self.scan.config.get("dns_resolution", True) + self.dns_disable = self.scan.config.get("dns", {}).get("disable", False) self.org_stubs_seen = set() port_string = self.config.get("ports", "80,443") @@ -67,7 +69,7 @@ async def setup(self): self.hugewarning( f"Selected target ({target_len:,} hosts) is too large, skipping IP_RANGE --> IP_ADDRESS speculation" ) - self.hugewarning(f"Enabling a port scanner (nmap or masscan) module is highly recommended") + self.hugewarning(f'Enabling the "portscan" module is highly recommended') self.range_to_ip = False return True @@ -79,13 +81,21 @@ async def handle_event(self, event): ips = list(net) random.shuffle(ips) for ip in ips: - await self.emit_event(ip, "IP_ADDRESS", source=event, internal=True) + await self.emit_event( + ip, + "IP_ADDRESS", + parent=event, + internal=True, + context=f"speculate converted range into individual IP_ADDRESS: {ip}", + ) # parent domains - if event.type == "DNS_NAME": + if event.type.startswith("DNS_NAME"): parent = self.helpers.parent_domain(event.data) if parent != event.data: - await self.emit_event(parent, "DNS_NAME", source=event, internal=True) + await self.emit_event( + parent, "DNS_NAME", parent=event, context=f"speculated parent {{event.type}}: {{event.data}}" + ) # we speculate on distance-1 stuff too, because distance-1 open ports are needed by certain modules like sslcert event_in_scope_distance = event.scope_distance <= (self.scan.scope_search_distance + 1) @@ -98,42 +108,48 @@ async def handle_event(self, event): await self.emit_event( self.helpers.make_netloc(event.host, event.port), "OPEN_TCP_PORT", - source=event, + parent=event, internal=True, quick=(event.type == "URL"), + context=f"speculated {{event.type}} from {event.type}: {{event.data}}", ) # speculate sub-directory URLS from URLS if event.type == "URL": url_parents = self.helpers.url_parents(event.data) for up in url_parents: - url_event = self.make_event(f"{up}/", "URL_UNVERIFIED", source=event) + url_event = self.make_event(f"{up}/", "URL_UNVERIFIED", parent=event) if url_event is not None: # inherit web spider distance from parent (don't increment) - source_web_spider_distance = getattr(event, "web_spider_distance", 0) - url_event.web_spider_distance = source_web_spider_distance - await self.emit_event(url_event) + parent_web_spider_distance = getattr(event, "web_spider_distance", 0) + url_event.web_spider_distance = parent_web_spider_distance + await self.emit_event(url_event, context="speculated web sub-directory {event.type}: {event.data}") # speculate URL_UNVERIFIED from URL or any event with "url" attribute event_is_url = event.type == "URL" event_has_url = isinstance(event.data, dict) and "url" in event.data + event_tags = ["httpx-safe"] if event.type in ("CODE_REPOSITORY", "SOCIAL") else [] if event_is_url or event_has_url: if event_is_url: url = event.data else: url = event.data["url"] - if not any(e.type == "URL_UNVERIFIED" and e.data == url for e in event.get_sources()): - tags = None - if self.helpers.is_spider_danger(event.source, url): - tags = ["spider-danger"] - await self.emit_event(url, "URL_UNVERIFIED", tags=tags, source=event) + # only emit the url if it's not already in the event's history + if not any(e.type == "URL_UNVERIFIED" and e.data == url for e in event.get_parents()): + await self.emit_event( + url, + "URL_UNVERIFIED", + tags=event_tags, + parent=event, + context="speculated {event.type}: {event.data}", + ) - # from hosts + # IP_ADDRESS / DNS_NAME --> OPEN_TCP_PORT if speculate_open_ports: # don't act on unresolved DNS_NAMEs usable_dns = False if event.type == "DNS_NAME": - if (not self.dns_resolution) or ("a-record" in event.tags or "aaaa-record" in event.tags): + if self.dns_disable or ("a-record" in event.tags or "aaaa-record" in event.tags): usable_dns = True if event.type == "IP_ADDRESS" or usable_dns: @@ -141,14 +157,12 @@ async def handle_event(self, event): await self.emit_event( self.helpers.make_netloc(event.data, port), "OPEN_TCP_PORT", - source=event, + parent=event, internal=True, quick=True, + context="speculated {event.type}: {event.data}", ) - # storage buckets etc. - self.helpers.cloud.speculate(event) - # ORG_STUB from TLD, SOCIAL, AZURE_TENANT org_stubs = set() if event.type == "DNS_NAME" and event.scope_distance == 0: @@ -171,23 +185,17 @@ async def handle_event(self, event): stub_hash = hash(stub) if stub_hash not in self.org_stubs_seen: self.org_stubs_seen.add(stub_hash) - stub_event = self.make_event(stub, "ORG_STUB", source=event) + stub_event = self.make_event(stub, "ORG_STUB", parent=event) if stub_event: if event.scope_distance > 0: stub_event.scope_distance = event.scope_distance - await self.emit_event(stub_event) + await self.emit_event(stub_event, context="speculated {event.type}: {event.data}") # USERNAME --> EMAIL if event.type == "USERNAME": email = event.data.split(":", 1)[-1] if validators.soft_validate(email, "email"): - email_event = self.make_event(email, "EMAIL_ADDRESS", source=event, tags=["affiliate"]) + email_event = self.make_event(email, "EMAIL_ADDRESS", parent=event, tags=["affiliate"]) if email_event: email_event.scope_distance = event.scope_distance - await self.emit_event(email_event) - - async def filter_event(self, event): - # don't accept errored DNS_NAMEs - if any(t in event.tags for t in ("unresolved", "a-error", "aaaa-error")): - return False, "there were errors resolving this hostname" - return True + await self.emit_event(email_event, context="detected {event.type}: {event.data}") diff --git a/bbot/modules/internetdb.py b/bbot/modules/internetdb.py index 50b0903cc..bfafec810 100644 --- a/bbot/modules/internetdb.py +++ b/bbot/modules/internetdb.py @@ -76,7 +76,7 @@ async def handle_event(self, event): return if data: if r.status_code == 200: - await self._parse_response(data=data, event=event) + await self._parse_response(data=data, event=event, ip=ip) elif r.status_code == 404: detail = data.get("detail", "") if detail: @@ -86,29 +86,44 @@ async def handle_event(self, event): err_msg = data.get("msg", "") self.verbose(f"Shodan error for {ip}: {err_data}: {err_msg}") - async def _parse_response(self, data: dict, event): + async def _parse_response(self, data: dict, event, ip): """Handles emitting events from returned JSON""" data: dict # has keys: cpes, hostnames, ip, ports, tags, vulns + ip = str(ip) + query_host = ip if event.data == ip else f"{event.data} ({ip})" # ip is a string, ports is a list of ports, the rest is a list of strings for hostname in data.get("hostnames", []): - await self.emit_event(hostname, "DNS_NAME", source=event) + if hostname != event.data: + await self.emit_event( + hostname, + "DNS_NAME", + parent=event, + context=f'{{module}} queried Shodan\'s InternetDB API for "{query_host}" and found {{event.type}}: {{event.data}}', + ) for cpe in data.get("cpes", []): - await self.emit_event({"technology": cpe, "host": str(event.host)}, "TECHNOLOGY", source=event) + await self.emit_event( + {"technology": cpe, "host": str(event.host)}, + "TECHNOLOGY", + parent=event, + context=f'{{module}} queried Shodan\'s InternetDB API for "{query_host}" and found {{event.type}}: {{event.data}}', + ) for port in data.get("ports", []): await self.emit_event( self.helpers.make_netloc(event.data, port), "OPEN_TCP_PORT", - source=event, + parent=event, internal=(not self.show_open_ports), quick=True, + context=f'{{module}} queried Shodan\'s InternetDB API for "{query_host}" and found {{event.type}}: {{event.data}}', ) vulns = data.get("vulns", []) if vulns: vulns_str = ", ".join([str(v) for v in vulns]) await self.emit_event( - {"description": f"Shodan reported verified vulnerabilities: {vulns_str}", "host": str(event.host)}, + {"description": f"Shodan reported possible vulnerabilities: {vulns_str}", "host": str(event.host)}, "FINDING", - source=event, + parent=event, + context=f'{{module}} queried Shodan\'s InternetDB API for "{query_host}" and found potential {{event.type}}: {vulns_str}', ) def get_ip(self, event): diff --git a/bbot/modules/ip2location.py b/bbot/modules/ip2location.py index 6e2bf4a25..af7dd5d94 100644 --- a/bbot/modules/ip2location.py +++ b/bbot/modules/ip2location.py @@ -62,4 +62,15 @@ async def handle_event(self, event): if error_msg: self.warning(error_msg) elif geo_data: - await self.emit_event(geo_data, "GEOLOCATION", event) + country = geo_data.get("country_name", "unknown country") + region = geo_data.get("region_name", "unknown region") + city = geo_data.get("city_name", "unknown city") + lat = geo_data.get("latitude", "") + long = geo_data.get("longitude", "") + description = f"{city}, {region}, {country} ({lat}, {long})" + await self.emit_event( + geo_data, + "GEOLOCATION", + event, + context=f'{{module}} queried IP2Location API for "{event.data}" and found {{event.type}}: {description}', + ) diff --git a/bbot/modules/ipneighbor.py b/bbot/modules/ipneighbor.py index e4cc1dd55..3aab345f2 100644 --- a/bbot/modules/ipneighbor.py +++ b/bbot/modules/ipneighbor.py @@ -39,4 +39,7 @@ async def handle_event(self, event): if ip_event: # keep the scope distance low to give it one more hop for DNS resolution # ip_event.scope_distance = max(1, event.scope_distance) - await self.emit_event(ip_event) + await self.emit_event( + ip_event, + context="{module} produced {event.type}: {event.data}", + ) diff --git a/bbot/modules/ipstack.py b/bbot/modules/ipstack.py index 0d81674a4..115a620ba 100644 --- a/bbot/modules/ipstack.py +++ b/bbot/modules/ipstack.py @@ -52,4 +52,15 @@ async def handle_event(self, event): if error_msg: self.warning(error_msg) elif geo_data: - await self.emit_event(geo_data, "GEOLOCATION", event) + country = geo_data.get("country_name", "unknown country") + region = geo_data.get("region_name", "unknown region") + city = geo_data.get("city", "unknown city") + lat = geo_data.get("latitude", "") + long = geo_data.get("longitude", "") + description = f"{city}, {region}, {country} ({lat}, {long})" + await self.emit_event( + geo_data, + "GEOLOCATION", + event, + context=f'{{module}} queried ipstack.com\'s API for "{event.data}" and found {{event.type}}: {description}', + ) diff --git a/bbot/modules/masscan.py b/bbot/modules/masscan.py deleted file mode 100644 index 502d5dc81..000000000 --- a/bbot/modules/masscan.py +++ /dev/null @@ -1,282 +0,0 @@ -import json -from contextlib import suppress - -from bbot.modules.templates.portscanner import portscanner - - -class masscan(portscanner): - flags = ["active", "portscan", "aggressive"] - watched_events = ["IP_ADDRESS", "IP_RANGE"] - produced_events = ["OPEN_TCP_PORT"] - meta = { - "description": "Port scan with masscan. By default, scans top 100 ports.", - "created_date": "2023-01-27", - "author": "@TheTechromancer", - } - options = { - "top_ports": 100, - "ports": "", - # ping scan at 600 packets/s ~= entire private IP space in 8 hours - "rate": 600, - "wait": 5, - "ping_first": False, - "ping_only": False, - "use_cache": False, - } - options_desc = { - "top_ports": "Top ports to scan (default 100) (to override, specify 'ports')", - "ports": "Ports to scan", - "rate": "Rate in packets per second", - "wait": "Seconds to wait for replies after scan is complete", - "ping_first": "Only portscan hosts that reply to pings", - "ping_only": "Ping sweep only, no portscan", - "use_cache": "Instead of scanning, use the results from the previous scan", - } - deps_ansible = [ - { - "name": "install dev tools", - "package": {"name": ["gcc", "git", "make"], "state": "present"}, - "become": True, - "ignore_errors": True, - }, - { - "name": "Download masscan source code", - "git": { - "repo": "https://github.com/robertdavidgraham/masscan.git", - "dest": "#{BBOT_TEMP}/masscan", - "single_branch": True, - "version": "master", - }, - }, - { - "name": "Build masscan", - "command": { - "chdir": "#{BBOT_TEMP}/masscan", - "cmd": "make -j", - "creates": "#{BBOT_TEMP}/masscan/bin/masscan", - }, - }, - { - "name": "Install masscan", - "copy": {"src": "#{BBOT_TEMP}/masscan/bin/masscan", "dest": "#{BBOT_TOOLS}/", "mode": "u+x,g+x,o+x"}, - }, - ] - batch_size = 1000000 - - async def setup(self): - self.top_ports = self.config.get("top_ports", 100) - self.rate = self.config.get("rate", 600) - self.wait = self.config.get("wait", 10) - self.ping_first = self.config.get("ping_first", False) - self.ping_only = self.config.get("ping_only", False) - self.use_cache = self.config.get("use_cache", False) - self.ports = self.config.get("ports", "") - if self.ports: - try: - self.helpers.parse_port_string(self.ports) - except ValueError as e: - return False, f"Error parsing ports: {e}" - self.alive_hosts = dict() - - _, invalid_targets = self._build_targets(self.scan.target) - if invalid_targets > 0: - self.warning( - f"Masscan can only accept IP addresses or IP ranges as target ({invalid_targets:,} targets were hostnames)" - ) - - self.run_time = self.helpers.make_date() - self.ping_cache = self.scan.home / f"masscan_ping.txt" - self.syn_cache = self.scan.home / f"masscan_syn.txt" - if self.use_cache: - files_exist = self.ping_cache.is_file() or self.syn_cache.is_file() - files_empty = self.helpers.filesize(self.ping_cache) == 0 and self.helpers.filesize(self.syn_cache) == 0 - if not files_exist: - return ( - False, - f"use_cache is True but could not find cache file at {self.ping_cache} or {self.syn_cache}", - ) - if files_empty: - return ( - False, - f"use_cache is True but could cached files {self.ping_cache} and {self.syn_cache} are empty", - ) - else: - self.helpers.depsinstaller.ensure_root(message="Masscan requires root privileges") - self.ping_cache_fd = None - self.syn_cache_fd = None - - return await super().setup() - - async def handle_batch(self, *events): - if self.use_cache: - await self.emit_from_cache() - else: - targets = [str(e.data) for e in events] - if not targets: - self.warning("No targets specified") - return - - # ping scan - if self.ping_first or self.ping_only: - self.verbose("Starting masscan (ping scan)") - - await self.masscan(targets, result_callback=self.append_alive_host, ping=True) - targets = ",".join(str(h) for h in self.alive_hosts) - if not targets: - self.warning("No hosts responded to pings") - return - - # TCP SYN scan - if not self.ping_only: - self.verbose("Starting masscan (TCP SYN scan)") - await self.masscan(targets, result_callback=self.emit_open_tcp_port) - else: - self.verbose("Only ping sweep was requested, skipping TCP SYN scan") - # save memory - self.alive_hosts.clear() - - async def masscan(self, targets, result_callback, ping=False): - target_file = self.helpers.tempfile(targets, pipe=False) - command = self._build_masscan_command(target_file, ping=ping) - stats_file = self.helpers.tempfile_tail(callback=self.verbose) - try: - with open(stats_file, "w") as stats_fh: - async for line in self.run_process_live(command, sudo=True, stderr=stats_fh): - await self.process_output(line, result_callback=result_callback) - finally: - for file in (stats_file, target_file): - file.unlink() - - def _build_masscan_command(self, target_file=None, dry_run=False, ping=False): - command = ( - "masscan", - "--excludefile", - str(self.exclude_file), - "--rate", - self.rate, - "--wait", - self.wait, - "--open-only", - "-oJ", - "-", - ) - if target_file is not None: - command += ("-iL", str(target_file)) - if ping: - command += ("--ping",) - else: - if self.ports: - command += ("-p", self.ports) - else: - command += ("--top-ports", str(self.top_ports)) - if dry_run: - command += ("--echo",) - return command - - async def process_output(self, line, result_callback): - try: - j = json.loads(line) - except Exception: - return - ip = j.get("ip", "") - if not ip: - return - ports = j.get("ports", []) - if not ports: - return - for p in ports: - proto = p.get("proto", "") - port_number = p.get("port", "") - if proto == "" or port_number == "": - continue - result = str(ip) - source = None - with suppress(KeyError): - source = self.alive_hosts[ip] - if proto != "icmp": - result = self.helpers.make_netloc(result, port_number) - if source is None: - source = self.make_event(ip, "IP_ADDRESS", source=self.get_source_event(ip)) - if not source: - continue - await self.emit_event(source) - await result_callback(result, source=source) - - async def append_alive_host(self, host, source): - host_event = self.make_event(host, "IP_ADDRESS", source=self.get_source_event(host)) - if host_event: - self.alive_hosts[host] = host_event - self._write_ping_result(host) - await self.emit_event(host_event) - - async def emit_open_tcp_port(self, data, source): - self._write_syn_result(data) - await self.emit_event(data, "OPEN_TCP_PORT", source=source) - - async def emit_from_cache(self): - ip_events = {} - # ping scan - if self.ping_cache.is_file(): - cached_pings = list(self.helpers.read_file(self.ping_cache)) - if cached_pings: - self.success(f"{len(cached_pings):,} hosts loaded from previous ping scan") - else: - self.verbose(f"No hosts cached from previous ping scan") - for ip in cached_pings: - if self.scan.stopping: - break - ip_event = self.make_event(ip, "IP_ADDRESS", source=self.get_source_event(ip)) - if ip_event: - ip_events[ip] = ip_event - await self.emit_event(ip_event) - # syn scan - if self.syn_cache.is_file(): - cached_syns = list(self.helpers.read_file(self.syn_cache)) - if cached_syns: - self.success(f"{len(cached_syns):,} hosts loaded from previous SYN scan") - else: - self.warning(f"No hosts cached from previous SYN scan") - for line in cached_syns: - if self.scan.stopping: - break - host, port = self.helpers.split_host_port(line) - host = str(host) - source_event = ip_events.get(host) - if source_event is None: - self.verbose(f"Source event not found for {line}") - source_event = self.make_event(line, "IP_ADDRESS", source=self.get_source_event(line)) - if not source_event: - continue - await self.emit_event(source_event) - await self.emit_event(line, "OPEN_TCP_PORT", source=source_event) - - def get_source_event(self, host): - source_event = self.scan.target.get(host) - if source_event is None: - source_event = self.scan.whitelist.get(host) - if source_event is None: - source_event = self.scan.root_event - return source_event - - async def cleanup(self): - if self.ping_first: - with suppress(Exception): - self.ping_cache_fd.close() - with suppress(Exception): - self.syn_cache_fd.close() - with suppress(Exception): - self.exclude_file.unlink() - - def _write_ping_result(self, host): - if self.ping_cache_fd is None: - self.helpers.backup_file(self.ping_cache) - self.ping_cache_fd = open(self.ping_cache, "w") - self.ping_cache_fd.write(f"{host}\n") - self.ping_cache_fd.flush() - - def _write_syn_result(self, data): - if self.syn_cache_fd is None: - self.helpers.backup_file(self.syn_cache) - self.syn_cache_fd = open(self.syn_cache, "w") - self.syn_cache_fd.write(f"{data}\n") - self.syn_cache_fd.flush() diff --git a/bbot/modules/massdns.py b/bbot/modules/massdns.py deleted file mode 100644 index 1a850d2b0..000000000 --- a/bbot/modules/massdns.py +++ /dev/null @@ -1,485 +0,0 @@ -import re -import json -import random -import asyncio -import subprocess - -from bbot.modules.templates.subdomain_enum import subdomain_enum - - -class massdns(subdomain_enum): - """ - This is BBOT's flagship subdomain enumeration module. - - It uses massdns to brute-force subdomains. - At the end of a scan, it will leverage BBOT's word cloud to recursively discover target-specific subdomain mutations. - - Each subdomain discovered via mutations is tagged with the "mutation" tag. This tag indicates the depth at which - the mutation was found. I.e. the first mutation will be tagged "mutation-1". The second one (a mutation of a - mutation) will be "mutation-2". Mutations of mutations of mutations will be "mutation-3", etc. - - This is especially useful for bug bounties because it enables you to recognize distant/rare subdomains at a glance. - Subdomains with higher mutation levels are more likely to be distant/rare or never-before-seen. - """ - - flags = ["subdomain-enum", "passive", "aggressive"] - watched_events = ["DNS_NAME"] - produced_events = ["DNS_NAME"] - meta = { - "description": "Brute-force subdomains with massdns (highly effective)", - "created_date": "2023-03-29", - "author": "@TheTechromancer", - } - options = { - "wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt", - "max_resolvers": 1000, - "max_mutations": 500, - "max_depth": 5, - } - options_desc = { - "wordlist": "Subdomain wordlist URL", - "max_resolvers": "Number of concurrent massdns resolvers", - "max_mutations": "Max number of smart mutations per subdomain", - "max_depth": "How many subdomains deep to brute force, i.e. 5.4.3.2.1.evilcorp.com", - } - subdomain_file = None - deps_ansible = [ - { - "name": "install dev tools", - "package": {"name": ["gcc", "git", "make"], "state": "present"}, - "become": True, - "ignore_errors": True, - }, - { - "name": "Download massdns source code", - "git": { - "repo": "https://github.com/blechschmidt/massdns.git", - "dest": "#{BBOT_TEMP}/massdns", - "single_branch": True, - "version": "master", - }, - }, - { - "name": "Build massdns (Linux)", - "command": {"chdir": "#{BBOT_TEMP}/massdns", "cmd": "make", "creates": "#{BBOT_TEMP}/massdns/bin/massdns"}, - "when": "ansible_facts['system'] == 'Linux'", - }, - { - "name": "Build massdns (non-Linux)", - "command": { - "chdir": "#{BBOT_TEMP}/massdns", - "cmd": "make nolinux", - "creates": "#{BBOT_TEMP}/massdns/bin/massdns", - }, - "when": "ansible_facts['system'] != 'Linux'", - }, - { - "name": "Install massdns", - "copy": {"src": "#{BBOT_TEMP}/massdns/bin/massdns", "dest": "#{BBOT_TOOLS}/", "mode": "u+x,g+x,o+x"}, - }, - ] - reject_wildcards = "strict" - _qsize = 10000 - - digit_regex = re.compile(r"\d+") - - async def setup(self): - self.found = dict() - self.mutations_tried = set() - self.source_events = self.helpers.make_target() - self.subdomain_file = await self.helpers.wordlist(self.config.get("wordlist")) - self.subdomain_list = set(self.helpers.read_file(self.subdomain_file)) - - ms_on_prem_string_file = self.helpers.wordlist_dir / "ms_on_prem_subdomains.txt" - ms_on_prem_strings = set(self.helpers.read_file(ms_on_prem_string_file)) - self.subdomain_list.update(ms_on_prem_strings) - - self.max_resolvers = self.config.get("max_resolvers", 1000) - self.max_mutations = self.config.get("max_mutations", 500) - self.max_depth = max(1, self.config.get("max_depth", 5)) - nameservers_url = ( - "https://raw.githubusercontent.com/blacklanternsecurity/public-dns-servers/master/nameservers.txt" - ) - self.resolver_file = await self.helpers.wordlist( - nameservers_url, - cache_hrs=24 * 7, - ) - self.devops_mutations = list(self.helpers.word_cloud.devops_mutations) - self.mutation_run = 1 - - self.resolve_and_emit_queue = asyncio.Queue() - self.resolve_and_emit_task = asyncio.create_task(self.resolve_and_emit()) - return await super().setup() - - async def filter_event(self, event): - query = self.make_query(event) - eligible, reason = await self.eligible_for_enumeration(event) - - # limit brute force depth - subdomain_depth = self.helpers.subdomain_depth(query) + 1 - if subdomain_depth > self.max_depth: - eligible = False - reason = f"subdomain depth of *.{query} ({subdomain_depth}) > max_depth ({self.max_depth})" - - # don't brute-force things that look like autogenerated PTRs - if self.helpers.is_ptr(query): - eligible = False - reason = f'"{query}" looks like an autogenerated PTR' - - if eligible: - self.add_found(event) - # reject if already processed - if self.already_processed(query): - return False, f'Query "{query}" was already processed' - - if eligible: - self.processed.add(hash(query)) - return True, reason - return False, reason - - async def handle_event(self, event): - query = self.make_query(event) - self.source_events.add_target(event) - self.info(f"Brute-forcing subdomains for {query} (source: {event.data})") - results = await self.massdns(query, self.subdomain_list) - await self.resolve_and_emit_queue.put((results, event, None)) - - def abort_if(self, event): - if not event.scope_distance == 0: - return True, "event is not in scope" - if "wildcard" in event.tags: - return True, "event is a wildcard" - if "unresolved" in event.tags: - return True, "event is unresolved" - return False, "" - - def already_processed(self, hostname): - if hash(hostname) in self.processed: - return True - return False - - async def massdns(self, domain, subdomains): - subdomains = list(subdomains) - - domain_wildcard_rdtypes = set() - for _domain, rdtypes in (await self.helpers.is_wildcard_domain(domain)).items(): - for rdtype, results in rdtypes.items(): - if results: - domain_wildcard_rdtypes.add(rdtype) - if any([r in domain_wildcard_rdtypes for r in ("A", "CNAME")]): - self.info( - f"Aborting massdns on {domain} because it's a wildcard domain ({','.join(domain_wildcard_rdtypes)})" - ) - self.found.pop(domain, None) - return [] - else: - self.log.trace(f"{domain}: A is not in domain_wildcard_rdtypes:{domain_wildcard_rdtypes}") - - # before we start, do a canary check for wildcards - abort_msg = f"Aborting massdns on {domain} due to false positive" - canary_result = await self._canary_check(domain) - if canary_result: - self.info(abort_msg + f": {canary_result}") - return [] - else: - self.log.trace(f"Canary result for {domain}: {canary_result}") - - results = [] - async for hostname, ip, rdtype in self._massdns(domain, subdomains): - # allow brute-forcing of wildcard domains - # this is dead code but it's kinda cool so it can live here - if rdtype in domain_wildcard_rdtypes: - # skip wildcard checking on multi-level subdomains for performance reasons - stem = hostname.split(domain)[0].strip(".") - if "." in stem: - self.debug(f"Skipping {hostname}:A because it may be a wildcard (reason: performance)") - continue - wildcard_rdtypes = await self.helpers.is_wildcard(hostname, ips=(ip,), rdtype=rdtype) - if rdtype in wildcard_rdtypes: - self.debug(f"Skipping {hostname}:{rdtype} because it's a wildcard") - continue - results.append(hostname) - - # do another canary check for good measure - if len(results) > 50: - canary_result = await self._canary_check(domain) - if canary_result: - self.info(abort_msg + f": {canary_result}") - return [] - else: - self.log.trace(f"Canary result for {domain}: {canary_result}") - - # abort if there are a suspiciously high number of results - # (the results are over 2000, and this is more than 20 percent of the input size) - if len(results) > 2000: - if len(results) / len(subdomains) > 0.2: - self.info( - f"Aborting because the number of results ({len(results):,}) is suspiciously high for the length of the wordlist ({len(subdomains):,})" - ) - return [] - else: - self.info( - f"{len(results):,} results returned from massdns against {domain} (wordlist size = {len(subdomains):,})" - ) - - # everything checks out - return results - - async def resolve_and_emit(self): - """ - When results are found, they are placed into self.resolve_and_emit_queue. - The purpose of this function (which is started as a task in the module's setup()) is to consume results from - the queue, resolve them, and if they resolve, emit them. - - This exists to prevent disrupting the scan with huge batches of DNS resolutions. - """ - while 1: - results, source_event, tags = await self.resolve_and_emit_queue.get() - self.verbose(f"Resolving batch of {len(results):,} results") - async with self._task_counter.count(f"{self.name}.resolve_and_emit()"): - async for hostname, r in self.helpers.resolve_batch(results, type=("A", "CNAME")): - if not r: - self.debug(f"Discarding {hostname} because it didn't resolve") - continue - self.add_found(hostname) - if source_event is None: - source_event = self.source_events.get(hostname) - if source_event is None: - self.warning(f"Could not correlate source event from: {hostname}") - source_event = self.scan.root_event - kwargs = {"abort_if": self.abort_if, "tags": tags} - await self.emit_event(hostname, "DNS_NAME", source_event, **kwargs) - - @property - def running(self): - return super().running or self.resolve_and_emit_queue.qsize() > 0 - - async def _canary_check(self, domain, num_checks=50): - random_subdomains = list(self.gen_random_subdomains(num_checks)) - self.verbose(f"Testing {len(random_subdomains):,} canaries against {domain}") - canary_results = [h async for h, d, r in self._massdns(domain, random_subdomains)] - self.log.trace(f"canary results for {domain}: {canary_results}") - resolved_canaries = self.helpers.resolve_batch(canary_results) - self.log.trace(f"resolved canary results for {domain}: {canary_results}") - async for query, result in resolved_canaries: - if result: - await resolved_canaries.aclose() - result = f"{query}:{result}" - self.log.trace(f"Found false positive: {result}") - return result - self.log.trace(f"Passed canary check for {domain}") - return False - - async def _massdns(self, domain, subdomains): - """ - { - "name": "www.blacklanternsecurity.com.", - "type": "A", - "class": "IN", - "status": "NOERROR", - "data": { - "answers": [ - { - "ttl": 3600, - "type": "CNAME", - "class": "IN", - "name": "www.blacklanternsecurity.com.", - "data": "blacklanternsecurity.github.io." - }, - { - "ttl": 3600, - "type": "A", - "class": "IN", - "name": "blacklanternsecurity.github.io.", - "data": "185.199.108.153" - } - ] - }, - "resolver": "168.215.165.186:53" - } - """ - if self.scan.stopping: - return - - command = ( - "massdns", - "-r", - self.resolver_file, - "-s", - self.max_resolvers, - "-t", - "A", - "-o", - "J", - "-q", - ) - subdomains = self.gen_subdomains(subdomains, domain) - hosts_yielded = set() - async for line in self.run_process_live(command, stderr=subprocess.DEVNULL, input=subdomains): - try: - j = json.loads(line) - except json.decoder.JSONDecodeError: - self.debug(f"Failed to decode line: {line}") - continue - answers = j.get("data", {}).get("answers", []) - if type(answers) == list and len(answers) > 0: - answer = answers[0] - hostname = answer.get("name", "").strip(".").lower() - if hostname.endswith(f".{domain}"): - data = answer.get("data", "") - rdtype = answer.get("type", "").upper() - # avoid garbage answers like this: - # 8AAAA queries have been locally blocked by dnscrypt-proxy/Set block_ipv6 to false to disable this feature - if data and rdtype and not " " in data: - hostname_hash = hash(hostname) - if hostname_hash not in hosts_yielded: - hosts_yielded.add(hostname_hash) - yield hostname, data, rdtype - - async def finish(self): - found = sorted(self.found.items(), key=lambda x: len(x[-1]), reverse=True) - # if we have a lot of rounds to make, don't try mutations on less-populated domains - trimmed_found = [] - if found: - avg_subdomains = sum([len(subdomains) for domain, subdomains in found[:50]]) / len(found[:50]) - for i, (domain, subdomains) in enumerate(found): - # accept domains that are in the top 50 or have more than 5 percent of the average number of subdomains - if i < 50 or (len(subdomains) > 1 and len(subdomains) >= (avg_subdomains * 0.05)): - trimmed_found.append((domain, subdomains)) - else: - self.verbose( - f"Skipping mutations on {domain} because it only has {len(subdomains):,} subdomain(s) (avg: {avg_subdomains:,})" - ) - - base_mutations = set() - found_mutations = False - try: - for i, (domain, subdomains) in enumerate(trimmed_found): - self.verbose(f"{domain} has {len(subdomains):,} subdomains") - # keep looping as long as we're finding things - while 1: - max_mem_percent = 90 - mem_status = self.helpers.memory_status() - # abort if we don't have the memory - mem_percent = mem_status.percent - if mem_percent > max_mem_percent: - free_memory = mem_status.available - free_memory_human = self.helpers.bytes_to_human(free_memory) - assert ( - False - ), f"Cannot proceed with DNS mutations because system memory is at {mem_percent:.1f}% ({free_memory_human} remaining)" - - query = domain - domain_hash = hash(domain) - if self.scan.stopping: - return - - mutations = set(base_mutations) - - def add_mutation(_domain_hash, m): - h = hash((_domain_hash, m)) - if h not in self.mutations_tried: - self.mutations_tried.add(h) - mutations.add(m) - - num_base_mutations = len(base_mutations) - self.debug(f"Base mutations for {domain}: {num_base_mutations:,}") - - # try every subdomain everywhere else - for _domain, _subdomains in found: - if _domain == domain: - continue - for s in _subdomains: - first_segment = s.split(".")[0] - # skip stuff with lots of numbers (e.g. PTRs) - if self.has_excessive_digits(first_segment): - continue - add_mutation(domain_hash, first_segment) - for word in self.helpers.extract_words( - first_segment, word_regexes=self.helpers.word_cloud.dns_mutator.extract_word_regexes - ): - add_mutation(domain_hash, word) - - num_massdns_mutations = len(mutations) - num_base_mutations - self.debug(f"Mutations from previous subdomains for {domain}: {num_massdns_mutations:,}") - - # numbers + devops mutations - for mutation in self.helpers.word_cloud.mutations( - subdomains, cloud=False, numbers=3, number_padding=1 - ): - for delimiter in ("", ".", "-"): - m = delimiter.join(mutation).lower() - add_mutation(domain_hash, m) - - num_word_cloud_mutations = len(mutations) - num_massdns_mutations - self.debug(f"Mutations added by word cloud for {domain}: {num_word_cloud_mutations:,}") - - # special dns mutator - self.debug( - f"DNS Mutator size: {len(self.helpers.word_cloud.dns_mutator):,} (limited to {self.max_mutations:,})" - ) - for subdomain in self.helpers.word_cloud.dns_mutator.mutations( - subdomains, max_mutations=self.max_mutations - ): - add_mutation(domain_hash, subdomain) - - num_mutations = len(mutations) - num_word_cloud_mutations - self.debug(f"Mutations added by DNS Mutator: {num_mutations:,}") - - if mutations: - self.info(f"Trying {len(mutations):,} mutations against {domain} ({i+1}/{len(found)})") - results = list(await self.massdns(query, mutations)) - if results: - await self.resolve_and_emit_queue.put((results, None, [f"mutation-{self.mutation_run}"])) - found_mutations = True - continue - break - except AssertionError as e: - self.warning(e) - - if found_mutations: - self.mutation_run += 1 - - def add_found(self, host): - if not isinstance(host, str): - host = host.data - if self.helpers.is_subdomain(host): - subdomain, domain = host.split(".", 1) - is_ptr = self.helpers.is_ptr(subdomain) - in_scope = self.scan.in_scope(domain) - if in_scope and not is_ptr: - try: - self.found[domain].add(subdomain) - except KeyError: - self.found[domain] = set((subdomain,)) - - async def gen_subdomains(self, prefixes, domain): - for p in prefixes: - d = f"{p}.{domain}" - yield d - - def gen_random_subdomains(self, n=50): - delimiters = (".", "-") - lengths = list(range(3, 8)) - for i in range(0, max(0, n - 5)): - d = delimiters[i % len(delimiters)] - l = lengths[i % len(lengths)] - segments = list(random.choice(self.devops_mutations) for _ in range(l)) - segments.append(self.helpers.rand_string(length=8, digits=False)) - subdomain = d.join(segments) - yield subdomain - for _ in range(5): - yield self.helpers.rand_string(length=8, digits=False) - - def has_excessive_digits(self, d): - """ - Identifies dns names with excessive numbers, e.g.: - - w1-2-3.evilcorp.com - - ptr1234.evilcorp.com - """ - digits = self.digit_regex.findall(d) - excessive_digits = len(digits) > 2 - long_digits = any(len(d) > 3 for d in digits) - if excessive_digits or long_digits: - return True - return False diff --git a/bbot/modules/newsletters.py b/bbot/modules/newsletters.py index 11bb2a8b1..5f2bac729 100644 --- a/bbot/modules/newsletters.py +++ b/bbot/modules/newsletters.py @@ -52,4 +52,9 @@ async def handle_event(self, event): if result: description = f"Found a Newsletter Submission Form that could be used for email bombing attacks" data = {"host": str(_event.host), "description": description, "url": _event.data["url"]} - await self.emit_event(data, "FINDING", _event) + await self.emit_event( + data, + "FINDING", + _event, + context="{module} searched HTTP_RESPONSE and identified {event.type}: a Newsletter Submission Form that could be used for email bombing attacks", + ) diff --git a/bbot/modules/nmap.py b/bbot/modules/nmap.py deleted file mode 100644 index 16e7064be..000000000 --- a/bbot/modules/nmap.py +++ /dev/null @@ -1,142 +0,0 @@ -from lxml import etree -from bbot.modules.templates.portscanner import portscanner - - -class nmap(portscanner): - watched_events = ["IP_ADDRESS", "DNS_NAME", "IP_RANGE"] - produced_events = ["OPEN_TCP_PORT"] - flags = ["active", "portscan", "aggressive", "web-thorough"] - meta = { - "description": "Port scan with nmap. By default, scans top 100 ports.", - "created_date": "2022-03-12", - "author": "@TheTechromancer", - } - options = { - "top_ports": 100, - "ports": "", - "timing": "T4", - "skip_host_discovery": True, - } - options_desc = { - "top_ports": "Top ports to scan (default 100) (to override, specify 'ports')", - "ports": "Ports to scan", - "timing": "-T<0-5>: Set timing template (higher is faster)", - "skip_host_discovery": "skip host discovery (-Pn)", - } - _max_event_handlers = 2 - batch_size = 256 - _priority = 2 - - deps_apt = ["nmap"] - deps_pip = ["lxml~=4.9.2"] - - async def setup(self): - self.helpers.depsinstaller.ensure_root(message="Nmap requires root privileges") - self.ports = self.config.get("ports", "") - self.timing = self.config.get("timing", "T4") - self.top_ports = self.config.get("top_ports", 100) - self.skip_host_discovery = self.config.get("skip_host_discovery", True) - return await super().setup() - - async def handle_batch(self, *events): - target = self.helpers.make_target(*events) - targets = list(set(str(e.data) for e in events)) - command, output_file = self.construct_command(targets) - try: - await self.run_process(command, sudo=True) - for host in self.parse_nmap_xml(output_file): - source_event = None - for h in [host.address] + host.hostnames: - source_event = target.get(h) - if source_event is not None: - break - if source_event is None: - self.warning(f"Failed to correlate source event from {host}") - source_event = self.scan.root_event - for port in host.open_ports: - port_number = int(port.split("/")[0]) - netloc = self.helpers.make_netloc(host.address, port_number) - await self.emit_event(netloc, "OPEN_TCP_PORT", source=source_event) - for hostname in host.hostnames: - netloc = self.helpers.make_netloc(hostname, port_number) - await self.emit_event(netloc, "OPEN_TCP_PORT", source=source_event) - finally: - output_file.unlink(missing_ok=True) - - def construct_command(self, targets): - ports = self.config.get("ports", "") - top_ports = self.config.get("top_ports", "") - temp_filename = self.helpers.temp_filename(extension="xml") - command = [ - "nmap", - "--noninteractive", - "--excludefile", - str(self.exclude_file), - "-n", - "--resolve-all", - f"-{self.timing}", - "-oX", - temp_filename, - ] - if self.skip_host_discovery: - command += ["-Pn"] - if ports: - command += ["-p", ports] - else: - command += ["--top-ports", top_ports] - command += targets - return command, temp_filename - - def parse_nmap_xml(self, xml_file): - try: - with open(xml_file, "rb") as f: - et = etree.parse(f) - for host in et.iter("host"): - yield NmapHost(host) - except Exception as e: - self.warning(f"Error parsing Nmap XML at {xml_file}: {e}") - - async def cleanup(self): - resume_file = self.helpers.current_dir / "resume.cfg" - resume_file.unlink(missing_ok=True) - - -class NmapHost(str): - def __init__(self, xml): - self.etree = xml - - # convenient host information - self.status = self.etree.find("status").attrib.get("state", "down") - self.address = self.etree.find("address").attrib.get("addr", "") - self.hostnames = [] - for hostname in self.etree.findall("hostnames/hostname"): - hostname = hostname.attrib.get("name") - if hostname and not hostname in self.hostnames: - self.hostnames.append(hostname) - - # convenient port information - self.scripts = dict() - self.open_ports = [] - self.closed_ports = [] - self.filtered_ports = [] - for port in self.etree.findall("ports/port"): - port_name = port.attrib.get("portid", "0") + "/" + port.attrib.get("protocol", "tcp").lower() - port_status = port.find("state").attrib.get("state", "closed") - if port_status in ("open", "closed", "filtered"): - getattr(self, f"{port_status}_ports").append(port_name) - for script in port.iter("script"): - script_name = script.attrib.get("id", "") - script_output = script.attrib.get("output", "") - if script_name: - try: - self.scripts[port_name][script_name] = script_output - except KeyError: - self.scripts[port_name] = {script_name: script_output} - - def __str__(self): - address = self.address + (" " if self.address else "") - hostnames = "(" + ", ".join(self.hostnames) + ")" if self.hostnames else "" - return f"{address}{hostnames}" - - def __repr__(self): - return str(self) diff --git a/bbot/modules/ntlm.py b/bbot/modules/ntlm.py index ad963c4b5..c05b48103 100644 --- a/bbot/modules/ntlm.py +++ b/bbot/modules/ntlm.py @@ -1,4 +1,4 @@ -from bbot.core.errors import NTLMError +from bbot.errors import NTLMError from bbot.modules.base import BaseModule ntlm_discovery_endpoints = [ @@ -68,7 +68,7 @@ class ntlm(BaseModule): watched_events = ["URL", "HTTP_RESPONSE"] produced_events = ["FINDING", "DNS_NAME"] - flags = ["active", "safe", "web-basic", "web-thorough"] + flags = ["active", "safe", "web-basic"] meta = { "description": "Watch for HTTP endpoints that support NTLM authentication", "created_date": "2022-07-25", @@ -80,15 +80,19 @@ class ntlm(BaseModule): in_scope_only = True async def setup(self): - self.processed = set() self.found = set() self.try_all = self.config.get("try_all", False) return True async def handle_event(self, event): found_hash = hash(f"{event.host}:{event.port}") + if event.type == "URL": + url = event.data + else: + url = event.data["url"] + agen = self.handle_url(url, event) if found_hash not in self.found: - for result, request_url in await self.handle_url(event): + async for result, request_url, num_urls in agen: if result and request_url: self.found.add(found_hash) await self.emit_event( @@ -98,11 +102,13 @@ async def handle_event(self, event): "description": f"NTLM AUTH: {result}", }, "FINDING", - source=event, + parent=event, + context=f"{{module}} tried {num_urls:,} NTLM endpoints against {url} and identified NTLM auth ({{event.type}}): {result}", ) fqdn = result.get("FQDN", "") if fqdn: - await self.emit_event(fqdn, "DNS_NAME", source=event) + await self.emit_event(fqdn, "DNS_NAME", parent=event) + await agen.aclose() break async def filter_event(self, event): @@ -110,46 +116,27 @@ async def filter_event(self, event): return True if event.type == "HTTP_RESPONSE": if "www-authenticate" in event.data["header-dict"]: - header_value = event.data["header-dict"]["www-authenticate"].lower() + header_value = event.data["header-dict"]["www-authenticate"][0].lower() if "ntlm" in header_value or "negotiate" in header_value: return True return False - async def handle_url(self, event): - if event.type == "URL": - urls = { - event.data, - } - else: - urls = { - event.data["url"], - } + async def handle_url(self, url, event): + urls = {url} if self.try_all: for endpoint in ntlm_discovery_endpoints: - urls.add(f"{event.parsed.scheme}://{event.parsed.netloc}/{endpoint}") - - tasks = [] - for url in urls: - url_hash = hash(url) - if url_hash in self.processed: - continue - self.processed.add(url_hash) - tasks.append(self.helpers.create_task(self.check_ntlm(url))) - - return await self.helpers.gather(*tasks) + urls.add(f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/{endpoint}") - async def check_ntlm(self, test_url): - # use lower timeout value - http_timeout = self.config.get("httpx_timeout", 5) - r = await self.helpers.request(test_url, headers=NTLM_test_header, allow_redirects=False, timeout=http_timeout) - ntlm_resp = r.headers.get("WWW-Authenticate", "") - if ntlm_resp: - ntlm_resp_b64 = max(ntlm_resp.split(","), key=lambda x: len(x)).split()[-1] - try: - ntlm_resp_decoded = self.helpers.ntlm.ntlmdecode(ntlm_resp_b64) - if ntlm_resp_decoded: - return ntlm_resp_decoded, test_url - except NTLMError as e: - self.verbose(str(e)) - return None, test_url - return None, test_url + num_urls = len(urls) + async for url, response in self.helpers.request_batch( + urls, headers=NTLM_test_header, allow_redirects=False, timeout=self.http_timeout + ): + ntlm_resp = response.headers.get("WWW-Authenticate", "") + if ntlm_resp: + ntlm_resp_b64 = max(ntlm_resp.split(","), key=lambda x: len(x)).split()[-1] + try: + ntlm_resp_decoded = self.helpers.ntlm.ntlmdecode(ntlm_resp_b64) + if ntlm_resp_decoded: + yield ntlm_resp_decoded, url, num_urls + except NTLMError as e: + self.verbose(str(e)) diff --git a/bbot/modules/oauth.py b/bbot/modules/oauth.py index 32418848d..58c0507c0 100644 --- a/bbot/modules/oauth.py +++ b/bbot/modules/oauth.py @@ -6,7 +6,7 @@ class OAUTH(BaseModule): watched_events = ["DNS_NAME", "URL_UNVERIFIED"] produced_events = ["DNS_NAME"] - flags = ["affiliates", "subdomain-enum", "cloud-enum", "web-basic", "web-thorough", "active", "safe"] + flags = ["affiliates", "subdomain-enum", "cloud-enum", "web-basic", "active", "safe"] meta = { "description": "Enumerate OAUTH and OpenID Connect services", "created_date": "2023-07-12", @@ -17,7 +17,7 @@ class OAUTH(BaseModule): in_scope_only = False scope_distance_modifier = 1 - _max_event_handlers = 2 + _module_threads = 2 async def setup(self): self.processed = set() @@ -67,37 +67,53 @@ async def handle_event(self, event): "url": url, }, "FINDING", - source=event, + parent=event, ) if finding_event: finding_event.source_domain = source_domain - await self.emit_event(finding_event) + await self.emit_event( + finding_event, + context=f'{{module}} identified {{event.type}}: OpenID Connect Endpoint for "{source_domain}" at {url}', + ) url_event = self.make_event( - token_endpoint, "URL_UNVERIFIED", source=event, tags=["affiliate", "oauth-token-endpoint"] + token_endpoint, "URL_UNVERIFIED", parent=event, tags=["affiliate", "oauth-token-endpoint"] ) if url_event: url_event.source_domain = source_domain - await self.emit_event(url_event) + await self.emit_event( + url_event, + context=f'{{module}} identified OpenID Connect Endpoint for "{source_domain}" at {{event.type}}: {url}', + ) for result in oidc_results: if result not in (domain, event.data): event_type = "URL_UNVERIFIED" if self.helpers.is_url(result) else "DNS_NAME" - await self.emit_event(result, event_type, source=event, tags=["affiliate"]) + await self.emit_event( + result, + event_type, + parent=event, + tags=["affiliate"], + context=f'{{module}} analyzed OpenID configuration for "{source_domain}" and found {{event.type}}: {{event.data}}', + ) for oauth_task in oauth_tasks: url = await oauth_task if url: + description = f"Potentially Sprayable OAUTH Endpoint (domain: {source_domain}) at {url}" oauth_finding = self.make_event( { - "description": f"Potentially Sprayable OAUTH Endpoint (domain: {source_domain}) at {url}", + "description": description, "host": event.host, "url": url, }, "FINDING", - source=event, + parent=event, ) if oauth_finding: oauth_finding.source_domain = source_domain - await self.emit_event(oauth_finding) + await self.emit_event( + oauth_finding, + context=f"{{module}} identified {{event.type}}: {description}", + ) def url_and_base(self, url): yield url @@ -123,7 +139,7 @@ async def getoidc(self, url): return url, token_endpoint, results if json and isinstance(json, dict): token_endpoint = json.get("token_endpoint", "") - for found in self.helpers.search_dict_values(json, *self.regexes): + for found in await self.helpers.re.search_dict_values(json, *self.regexes): results.add(found) results -= {token_endpoint} return url, token_endpoint, results diff --git a/bbot/modules/output/asset_inventory.py b/bbot/modules/output/asset_inventory.py index 82a67e97c..a150c029d 100644 --- a/bbot/modules/output/asset_inventory.py +++ b/bbot/modules/output/asset_inventory.py @@ -183,25 +183,35 @@ async def finish(self): self.add_custom_headers(list(asset.custom_fields)) if not is_ip(asset.host): host_event = self.make_event( - asset.host, "DNS_NAME", source=self.scan.root_event, raise_error=True + asset.host, "DNS_NAME", parent=self.scan.root_event, raise_error=True + ) + await self.emit_event( + host_event, context="{module} emitted previous result: {event.type}: {event.data}" ) - await self.emit_event(host_event) for port in asset.ports: netloc = self.helpers.make_netloc(asset.host, port) - open_port_event = self.make_event(netloc, "OPEN_TCP_PORT", source=host_event) + open_port_event = self.make_event(netloc, "OPEN_TCP_PORT", parent=host_event) if open_port_event: - await self.emit_event(open_port_event) + await self.emit_event( + open_port_event, + context="{module} emitted previous result: {event.type}: {event.data}", + ) else: for ip in asset.ip_addresses: ip_event = self.make_event( - ip, "IP_ADDRESS", source=self.scan.root_event, raise_error=True + ip, "IP_ADDRESS", parent=self.scan.root_event, raise_error=True + ) + await self.emit_event( + ip_event, context="{module} emitted previous result: {event.type}: {event.data}" ) - await self.emit_event(ip_event) for port in asset.ports: netloc = self.helpers.make_netloc(ip, port) - open_port_event = self.make_event(netloc, "OPEN_TCP_PORT", source=ip_event) + open_port_event = self.make_event(netloc, "OPEN_TCP_PORT", parent=ip_event) if open_port_event: - await self.emit_event(open_port_event) + await self.emit_event( + open_port_event, + context="{module} emitted previous result: {event.type}: {event.data}", + ) else: self.warning( f"use_previous=True was set but no previous asset inventory was found at {self.output_file}" diff --git a/bbot/modules/output/base.py b/bbot/modules/output/base.py index 98c3f0cbc..8a6eba9eb 100644 --- a/bbot/modules/output/base.py +++ b/bbot/modules/output/base.py @@ -8,6 +8,15 @@ class BaseOutputModule(BaseModule): _type = "output" scope_distance_modifier = None _stats_exclude = True + _shuffle_incoming_queue = False + + def human_event_str(self, event): + event_type = f"[{event.type}]" + event_tags = "" + if getattr(event, "tags", []): + event_tags = f'\t({", ".join(sorted(getattr(event, "tags", [])))})' + event_str = f"{event_type:<20}\t{event.data_human}\t{event.module_sequence}{event_tags}" + return event_str def _event_precheck(self, event): # special signal event types @@ -21,19 +30,30 @@ def _event_precheck(self, event): if self.target_only: if "target" not in event.tags: return False, "it did not meet target_only filter criteria" - # exclude certain URLs (e.g. javascript): - if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: - return False, "its extension was listed in url_extension_httpx_only" - # output module specific stuff - # omitted events such as HTTP_RESPONSE etc. - if event._omit and not event.type in self.get_watched_events(): - return False, "_omit is True" + ### begin output-module specific ### # force-output certain events to the graph if self._is_graph_important(event): return True, "event is critical to the graph" + # exclude certain URLs (e.g. javascript): + # TODO: revisit this after httpx rework + if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags: + return False, (f"Omitting {event} from output because it's marked as httpx-only") + + if event._omit: + return False, "_omit is True" + + # omit certain event types + if event.type in self.scan.omitted_event_types: + if "target" in event.tags: + self.debug(f"Allowing omitted event: {event} because it's a target") + elif event.type in self.get_watched_events(): + self.debug(f"Allowing omitted event: {event} because its type is explicitly in watched_events") + else: + return False, "its type is omitted in the config" + # internal events like those from speculate, ipneighbor # or events that are over our report distance if event._internal: @@ -73,13 +93,6 @@ def file(self): self._file = open(self.output_file, mode="a") return self._file - @property - def config(self): - config = self.scan.config.get("output_modules", {}).get(self.name, {}) - if config is None: - config = {} - return config - @property def log(self): if self._log is None: diff --git a/bbot/modules/output/csv.py b/bbot/modules/output/csv.py index 3fd28f545..d48e9cd1d 100644 --- a/bbot/modules/output/csv.py +++ b/bbot/modules/output/csv.py @@ -10,7 +10,15 @@ class CSV(BaseOutputModule): options = {"output_file": ""} options_desc = {"output_file": "Output to CSV file"} - header_row = ["Event type", "Event data", "IP Address", "Source Module", "Scope Distance", "Event Tags"] + header_row = [ + "Event type", + "Event data", + "IP Address", + "Source Module", + "Scope Distance", + "Event Tags", + "Discovery Path", + ] filename = "output.csv" accept_dupes = False @@ -56,6 +64,7 @@ async def handle_event(self, event): "Source Module": str(getattr(event, "module_sequence", "")), "Scope Distance": str(getattr(event, "scope_distance", "")), "Event Tags": ",".join(sorted(list(getattr(event, "tags", [])))), + "Discovery Path": " --> ".join(getattr(event, "discovery_path", [])), } ) diff --git a/bbot/modules/output/emails.py b/bbot/modules/output/emails.py index 1b0590633..60d9a153c 100644 --- a/bbot/modules/output/emails.py +++ b/bbot/modules/output/emails.py @@ -1,8 +1,8 @@ +from bbot.modules.output.txt import TXT from bbot.modules.base import BaseModule -from bbot.modules.output.human import Human -class Emails(Human): +class Emails(TXT): watched_events = ["EMAIL_ADDRESS"] flags = ["email-enum"] meta = { @@ -13,6 +13,7 @@ class Emails(Human): options = {"output_file": ""} options_desc = {"output_file": "Output to file"} in_scope_only = True + accept_dupes = False output_filename = "emails.txt" diff --git a/bbot/modules/output/http.py b/bbot/modules/output/http.py index e4ed2ddbc..e4bc79562 100644 --- a/bbot/modules/output/http.py +++ b/bbot/modules/output/http.py @@ -1,5 +1,4 @@ -from bbot.core.errors import RequestError - +from bbot.errors import WebError from bbot.modules.output.base import BaseOutputModule @@ -63,6 +62,6 @@ async def handle_event(self, event): raise_error=True, ) break - except RequestError as e: + except WebError as e: self.warning(f"Error sending {event}: {e}, retrying...") await self.helpers.sleep(1) diff --git a/bbot/modules/output/human.py b/bbot/modules/output/human.py deleted file mode 100644 index 37b812214..000000000 --- a/bbot/modules/output/human.py +++ /dev/null @@ -1,49 +0,0 @@ -from contextlib import suppress - -from bbot.core.helpers.logger import log_to_stderr -from bbot.modules.output.base import BaseOutputModule - - -class Human(BaseOutputModule): - watched_events = ["*"] - meta = {"description": "Output to text", "created_date": "2022-04-07", "author": "@TheTechromancer"} - options = {"output_file": "", "console": True} - options_desc = {"output_file": "Output to file", "console": "Output to console"} - vuln_severity_map = {"LOW": "HUGEWARNING", "MEDIUM": "HUGEWARNING", "HIGH": "CRITICAL", "CRITICAL": "CRITICAL"} - accept_dupes = False - - output_filename = "output.txt" - - async def setup(self): - self._prep_output_dir(self.output_filename) - return True - - async def handle_event(self, event): - 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{event.data_human}\t{event.module_sequence}{event_tags}" - # log vulnerabilities in vivid colors - if event.type == "VULNERABILITY": - severity = event.data.get("severity", "INFO") - 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) - - if self.file is not None: - self.file.write(event_str + "\n") - self.file.flush() - if self.config.get("console", True): - self.stdout(event_str) - - async def cleanup(self): - if getattr(self, "_file", None) is not None: - with suppress(Exception): - self.file.close() - - async def report(self): - if getattr(self, "_file", None) is not None: - self.info(f"Saved TXT output to {self.output_file}") diff --git a/bbot/modules/output/json.py b/bbot/modules/output/json.py index e4dcdfd01..a35fa6aed 100644 --- a/bbot/modules/output/json.py +++ b/bbot/modules/output/json.py @@ -11,16 +11,15 @@ class JSON(BaseOutputModule): "created_date": "2022-04-07", "author": "@TheTechromancer", } - options = {"output_file": "", "console": False, "siem_friendly": False} + options = {"output_file": "", "siem_friendly": False} options_desc = { "output_file": "Output to file", - "console": "Output to console", "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.ndjson") + self._prep_output_dir("output.json") self.siem_friendly = self.config.get("siem_friendly", False) return True @@ -30,8 +29,6 @@ async def handle_event(self, event): if self.file is not None: self.file.write(event_str + "\n") self.file.flush() - if self.config.get("console", False) or "human" not in self.scan.modules: - self.stdout(event_str) async def cleanup(self): if getattr(self, "_file", None) is not None: diff --git a/bbot/modules/output/neo4j.py b/bbot/modules/output/neo4j.py index f7ecc3a84..87220d26d 100644 --- a/bbot/modules/output/neo4j.py +++ b/bbot/modules/output/neo4j.py @@ -1,3 +1,4 @@ +from contextlib import suppress from neo4j import AsyncGraphDatabase from bbot.modules.output.base import BaseOutputModule @@ -52,7 +53,7 @@ async def setup(self): async def handle_event(self, event): # create events - src_id = await self.merge_event(event.get_source(), id_only=True) + src_id = await self.merge_event(event.get_parent(), id_only=True) dst_id = await self.merge_event(event) # create relationship cypher = f""" @@ -78,5 +79,7 @@ async def merge_event(self, event, id_only=False): return (await result.single()).get("id(_)") async def cleanup(self): - await self.session.close() - await self.driver.close() + with suppress(Exception): + await self.session.close() + with suppress(Exception): + await self.driver.close() diff --git a/bbot/modules/output/splunk.py b/bbot/modules/output/splunk.py index de3e8d131..0c0a0dd80 100644 --- a/bbot/modules/output/splunk.py +++ b/bbot/modules/output/splunk.py @@ -1,5 +1,4 @@ -from bbot.core.errors import RequestError - +from bbot.errors import WebError from bbot.modules.output.base import BaseOutputModule @@ -58,6 +57,6 @@ async def handle_event(self, event): raise_error=True, ) break - except RequestError as e: + except WebError as e: self.warning(f"Error sending {event}: {e}, retrying...") await self.helpers.sleep(1) diff --git a/bbot/modules/output/stdout.py b/bbot/modules/output/stdout.py new file mode 100644 index 000000000..6e4ccf5be --- /dev/null +++ b/bbot/modules/output/stdout.py @@ -0,0 +1,69 @@ +import json + +from bbot.logger import log_to_stderr +from bbot.modules.output.base import BaseOutputModule + + +class Stdout(BaseOutputModule): + watched_events = ["*"] + meta = {"description": "Output to text"} + options = {"format": "text", "event_types": [], "event_fields": [], "in_scope_only": False, "accept_dupes": True} + options_desc = { + "format": "Which text format to display, choices: text,json", + "event_types": "Which events to display, default all event types", + "event_fields": "Which event fields to display", + "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"} + format_choices = ["text", "json"] + + async def setup(self): + self.text_format = self.config.get("format", "text").strip().lower() + if not self.text_format in self.format_choices: + return ( + False, + f'Invalid text format choice, "{self.text_format}" (choices: {",".join(self.format_choices)})', + ) + self.accept_event_types = [str(s).upper() for s in self.config.get("event_types", [])] + self.show_event_fields = [str(s) for s in self.config.get("event_fields", [])] + self.in_scope_only = self.config.get("in_scope_only", False) + self.accept_dupes = self.config.get("accept_dupes", False) + return True + + async def filter_event(self, event): + if self.accept_event_types: + if not event.type in self.accept_event_types: + return False, f'Event type "{event.type}" is not in the allowed event_types' + return True + + async def handle_event(self, event): + 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} + + if self.text_format == "text": + await self.handle_text(event, event_json) + elif self.text_format == "json": + await self.handle_json(event, event_json) + + 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) + + # log vulnerabilities in vivid colors + if event.type == "VULNERABILITY": + severity = event.data.get("severity", "INFO") + 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) + + print(event_str) + + async def handle_json(self, event, event_json): + print(json.dumps(event_json)) diff --git a/bbot/modules/output/subdomains.py b/bbot/modules/output/subdomains.py index e8c0ec5a3..6c2bfb0b0 100644 --- a/bbot/modules/output/subdomains.py +++ b/bbot/modules/output/subdomains.py @@ -1,8 +1,8 @@ +from bbot.modules.output.txt import TXT from bbot.modules.base import BaseModule -from bbot.modules.output.human import Human -class Subdomains(Human): +class Subdomains(TXT): watched_events = ["DNS_NAME", "DNS_NAME_UNRESOLVED"] flags = ["subdomain-enum"] meta = { diff --git a/bbot/modules/output/teams.py b/bbot/modules/output/teams.py index e4f87fb71..64991f46d 100644 --- a/bbot/modules/output/teams.py +++ b/bbot/modules/output/teams.py @@ -14,7 +14,7 @@ class Teams(WebhookOutputModule): "event_types": "Types of events to send", "min_severity": "Only allow VULNERABILITY events of this severity or higher", } - _max_event_handlers = 5 + _module_threads = 5 good_status_code = 200 content_key = "text" diff --git a/bbot/modules/output/txt.py b/bbot/modules/output/txt.py new file mode 100644 index 000000000..68f86864d --- /dev/null +++ b/bbot/modules/output/txt.py @@ -0,0 +1,32 @@ +from contextlib import suppress + +from bbot.modules.output.base import BaseOutputModule + + +class TXT(BaseOutputModule): + watched_events = ["*"] + meta = {"description": "Output to text"} + options = {"output_file": ""} + options_desc = {"output_file": "Output to file"} + + output_filename = "output.txt" + + async def setup(self): + self._prep_output_dir(self.output_filename) + return True + + async def handle_event(self, event): + event_str = self.human_event_str(event) + + if self.file is not None: + self.file.write(event_str + "\n") + self.file.flush() + + async def cleanup(self): + if getattr(self, "_file", None) is not None: + with suppress(Exception): + self.file.close() + + async def report(self): + if getattr(self, "_file", None) is not None: + self.info(f"Saved TXT output to {self.output_file}") diff --git a/bbot/modules/output/web_report.py b/bbot/modules/output/web_report.py index ba69f9b2a..92ff98289 100644 --- a/bbot/modules/output/web_report.py +++ b/bbot/modules/output/web_report.py @@ -38,36 +38,36 @@ async def setup(self): async def handle_event(self, event): if event.type == "URL": - parsed = event.parsed + parsed = event.parsed_url host = f"{parsed.scheme}://{parsed.netloc}/" if host not in self.web_assets.keys(): self.web_assets[host] = {"URL": []} - source_chain = [] + parent_chain = [] - current_parent = event.source + current_parent = event.parent while not current_parent.type == "SCAN": - source_chain.append( + parent_chain.append( f" ({current_parent.module})---> [{current_parent.type}]:{html.escape(current_parent.pretty_string)}" ) - current_parent = current_parent.source + current_parent = current_parent.parent - source_chain.reverse() - source_chain_text = ( - "".join(source_chain) + parent_chain.reverse() + parent_chain_text = ( + "".join(parent_chain) + f" ({event.module})---> " + f"[{event.type}]:{html.escape(event.pretty_string)}" ) - self.web_assets[host]["URL"].append(f"**{html.escape(event.data)}**: {source_chain_text}") + self.web_assets[host]["URL"].append(f"**{html.escape(event.data)}**: {parent_chain_text}") else: - current_parent = event.source + current_parent = event.parent parsed = None while 1: if current_parent.type == "URL": - parsed = current_parent.parsed + parsed = current_parent.parsed_url break - current_parent = current_parent.source - if current_parent.source.type == "SCAN": + current_parent = current_parent.parent + if current_parent.parent.type == "SCAN": break if parsed: host = f"{parsed.scheme}://{parsed.netloc}/" diff --git a/bbot/modules/paramminer_cookies.py b/bbot/modules/paramminer_cookies.py index 6a113f7f7..a3b4619d4 100644 --- a/bbot/modules/paramminer_cookies.py +++ b/bbot/modules/paramminer_cookies.py @@ -6,7 +6,8 @@ class paramminer_cookies(paramminer_headers): Inspired by https://github.com/PortSwigger/param-miner """ - watched_events = ["HTTP_RESPONSE"] + watched_events = ["HTTP_RESPONSE", "WEB_PARAMETER"] + produced_events = ["WEB_PARAMETER"] produced_events = ["FINDING"] flags = ["active", "aggressive", "slow", "web-paramminer"] meta = { @@ -16,18 +17,18 @@ class paramminer_cookies(paramminer_headers): } options = { "wordlist": "", # default is defined within setup function - "http_extract": True, + "recycle_words": False, "skip_boring_words": True, } options_desc = { "wordlist": "Define the wordlist to be used to derive headers", - "http_extract": "Attempt to find additional wordlist words from the HTTP Response", + "recycle_words": "Attempt to use words found during the scan on all other endpoints", "skip_boring_words": "Remove commonly uninteresting words from the wordlist", } options_desc = {"wordlist": "Define the wordlist to be used to derive cookies"} scanned_hosts = [] boring_words = set() - _max_event_handlers = 12 + _module_threads = 12 in_scope_only = True compare_mode = "cookie" default_wordlist = "paramminer_parameters.txt" diff --git a/bbot/modules/paramminer_getparams.py b/bbot/modules/paramminer_getparams.py index c596dd0ec..e6f35f623 100644 --- a/bbot/modules/paramminer_getparams.py +++ b/bbot/modules/paramminer_getparams.py @@ -6,7 +6,8 @@ class paramminer_getparams(paramminer_headers): Inspired by https://github.com/PortSwigger/param-miner """ - watched_events = ["HTTP_RESPONSE"] + watched_events = ["HTTP_RESPONSE", "WEB_PARAMETER"] + produced_events = ["WEB_PARAMETER"] produced_events = ["FINDING"] flags = ["active", "aggressive", "slow", "web-paramminer"] meta = { @@ -17,15 +18,15 @@ class paramminer_getparams(paramminer_headers): scanned_hosts = [] options = { "wordlist": "", # default is defined within setup function - "http_extract": True, + "recycle_words": False, "skip_boring_words": True, } options_desc = { "wordlist": "Define the wordlist to be used to derive headers", - "http_extract": "Attempt to find additional wordlist words from the HTTP Response", + "recycle_words": "Attempt to use words found during the scan on all other endpoints", "skip_boring_words": "Remove commonly uninteresting words from the wordlist", } - boring_words = set() + boring_words = {"utm_source", "utm_campaign", "utm_medium", "utm_term", "utm_content"} in_scope_only = True compare_mode = "getparam" default_wordlist = "paramminer_parameters.txt" diff --git a/bbot/modules/paramminer_headers.py b/bbot/modules/paramminer_headers.py index 5aac4b304..ca2894ce3 100644 --- a/bbot/modules/paramminer_headers.py +++ b/bbot/modules/paramminer_headers.py @@ -1,6 +1,7 @@ +import re + +from bbot.errors import HttpCompareError from bbot.modules.base import BaseModule -from bbot.core.errors import HttpCompareError -from bbot.core.helpers.misc import extract_params_json, extract_params_xml, extract_params_html class paramminer_headers(BaseModule): @@ -8,8 +9,8 @@ class paramminer_headers(BaseModule): Inspired by https://github.com/PortSwigger/param-miner """ - watched_events = ["HTTP_RESPONSE"] - produced_events = ["FINDING"] + watched_events = ["HTTP_RESPONSE", "WEB_PARAMETER"] + produced_events = ["WEB_PARAMETER"] flags = ["active", "aggressive", "slow", "web-paramminer"] meta = { "description": "Use smart brute-force to check for common HTTP header parameters", @@ -18,12 +19,12 @@ class paramminer_headers(BaseModule): } options = { "wordlist": "", # default is defined within setup function - "http_extract": True, + "recycle_words": False, "skip_boring_words": True, } options_desc = { "wordlist": "Define the wordlist to be used to derive headers", - "http_extract": "Attempt to find additional wordlist words from the HTTP Response", + "recycle_words": "Attempt to use words found during the scan on all other endpoints", "skip_boring_words": "Remove commonly uninteresting words from the wordlist", } scanned_hosts = [] @@ -73,12 +74,16 @@ class paramminer_headers(BaseModule): "zx-request-id", "zx-timer", } - _max_event_handlers = 12 + _module_threads = 12 in_scope_only = True compare_mode = "header" default_wordlist = "paramminer_headers.txt" + header_regex = re.compile(r"^[!#$%&\'*+\-.^_`|~0-9a-zA-Z]+: [^\r\n]+$") + async def setup(self): + + self.recycle_words = self.config.get("recycle_words", True) self.event_dict = {} self.already_checked = set() wordlist = self.config.get("wordlist", "") @@ -92,10 +97,10 @@ async def setup(self): ) # check against the boring list (if the option is set) - if self.config.get("skip_boring_words", True): self.wl -= self.boring_words self.extracted_words_master = set() + return True def rand_string(self, *args, **kwargs): @@ -126,56 +131,67 @@ async def do_mining(self, wl, url, batch_size, compare_helper): async def process_results(self, event, results): url = event.data.get("url") for result, reasons, reflection in results: + paramtype = self.compare_mode.upper() + if paramtype == "HEADER": + if self.header_regex.match(result): + self.debug("rejecting parameter as it is not a valid header") + continue tags = [] if reflection: tags = ["http_reflection"] description = f"[Paramminer] {self.compare_mode.capitalize()}: [{result}] Reasons: [{reasons}] Reflection: [{str(reflection)}]" + reflected = "reflected " if reflection else "" + self.extracted_words_master.add(result) await self.emit_event( - {"host": str(event.host), "url": url, "description": description}, - "FINDING", + { + "host": str(event.host), + "url": url, + "type": paramtype, + "description": description, + "name": result, + }, + "WEB_PARAMETER", event, tags=tags, + context=f'{{module}} scanned {url} and identified {{event.type}}: {reflected}{self.compare_mode} parameter: "{result}"', ) async def handle_event(self, event): - url = event.data.get("url") - try: - compare_helper = self.helpers.http_compare(url) - except HttpCompareError as e: - self.debug(f"Error initializing compare helper: {e}") - return - batch_size = await self.count_test(url) - if batch_size == None or batch_size <= 0: - self.debug(f"Failed to get baseline max {self.compare_mode} count, aborting") - return - self.debug(f"Resolved batch_size at {str(batch_size)}") + # If recycle words is enabled, we will collect WEB_PARAMETERS we find to build our list in finish() + # We also collect any parameters of type "SPECULATIVE" + if event.type == "WEB_PARAMETER": + if self.recycle_words or (event.data.get("type") == "SPECULATIVE"): + parameter_name = event.data.get("name") + if self.config.get("skip_boring_words", True) and parameter_name not in self.boring_words: + self.extracted_words_master.add(parameter_name) - self.event_dict[url] = (event, batch_size) - - try: - if not await compare_helper.canary_check(url, mode=self.compare_mode): - raise HttpCompareError("failed canary check") - except HttpCompareError as e: - self.verbose(f'Aborting "{url}" ({e})') - return - - wl = set(self.wl) - if self.config.get("http_extract"): - extracted_words = self.load_extracted_words(event.data.get("body"), event.data.get("content_type")) - if extracted_words: - self.debug(f"Extracted {str(len(extracted_words))} words from {url}") - self.extracted_words_master.update(extracted_words - wl) - wl |= extracted_words + elif event.type == "HTTP_RESPONSE": + url = event.data.get("url") + try: + compare_helper = self.helpers.http_compare(url) + except HttpCompareError as e: + self.debug(f"Error initializing compare helper: {e}") + return + batch_size = await self.count_test(url) + if batch_size == None or batch_size <= 0: + self.debug(f"Failed to get baseline max {self.compare_mode} count, aborting") + return + self.debug(f"Resolved batch_size at {str(batch_size)}") - if self.config.get("skip_boring_words", True): - wl -= self.boring_words + self.event_dict[url] = (event, batch_size) + try: + if not await compare_helper.canary_check(url, mode=self.compare_mode): + raise HttpCompareError("failed canary check") + except HttpCompareError as e: + self.verbose(f'Aborting "{url}" ({e})') + return - try: - results = await self.do_mining(wl, url, batch_size, compare_helper) - except HttpCompareError as e: - self.debug(f"Encountered HttpCompareError: [{e}] for URL [{event.data}]") - await self.process_results(event, results) + try: + results = await self.do_mining(self.wl, url, batch_size, compare_helper) + except HttpCompareError as e: + self.debug(f"Encountered HttpCompareError: [{e}] for URL [{event.data}]") + await self.process_results(event, results) async def count_test(self, url): baseline = await self.helpers.request(url) @@ -199,16 +215,6 @@ def gen_count_args(self, url): yield header_count, (url,), {"headers": fake_headers} header_count -= 5 - def load_extracted_words(self, body, content_type): - if not body: - return None - if content_type and "json" in content_type.lower(): - return extract_params_json(body) - elif content_type and "xml" in content_type.lower(): - return extract_params_xml(body) - else: - return set(extract_params_html(body)) - async def binary_search(self, compare_helper, url, group, reasons=None, reflection=False): if reasons is None: reasons = [] @@ -234,16 +240,14 @@ async def check_batch(self, compare_helper, url, header_list): return await compare_helper.compare(url, headers=test_headers, check_reflection=(len(header_list) == 1)) async def finish(self): - untested_matches = self.extracted_words_master.copy() - if self.config.get("skip_boring_words", True): - untested_matches -= self.boring_words + untested_matches = sorted(list(self.extracted_words_master.copy())) for url, (event, batch_size) in list(self.event_dict.items()): try: compare_helper = self.helpers.http_compare(url) except HttpCompareError as e: self.debug(f"Error initializing compare helper: {e}") - return + continue untested_matches_copy = untested_matches.copy() for i in untested_matches: h = hash(i + url) @@ -253,4 +257,11 @@ async def finish(self): results = await self.do_mining(untested_matches_copy, url, batch_size, compare_helper) except HttpCompareError as e: self.debug(f"Encountered HttpCompareError: [{e}] for URL [{url}]") + continue await self.process_results(event, results) + + async def filter_event(self, event): + # We don't need to look at WEB_PARAMETERS that we produced + if str(event.module).startswith("paramminer"): + return False + return True diff --git a/bbot/modules/pgp.py b/bbot/modules/pgp.py index 54157930a..0c53c2ad4 100644 --- a/bbot/modules/pgp.py +++ b/bbot/modules/pgp.py @@ -10,10 +10,13 @@ class pgp(subdomain_enum): "created_date": "2022-08-10", "author": "@TheTechromancer", } + # TODO: scan for Web Key Directory (/.well-known/openpgpkey/) options = { "search_urls": [ "https://keyserver.ubuntu.com/pks/lookup?fingerprint=on&op=vindex&search=", "http://the.earth.li:11371/pks/lookup?fingerprint=on&op=vindex&search=", + "https://pgpkeys.eu/pks/lookup?search=&op=index", + "https://pgp.mit.edu/pks/lookup?search=&op=index", ] } options_desc = {"search_urls": "PGP key servers to search"} @@ -22,18 +25,25 @@ async def handle_event(self, event): query = self.make_query(event) results = await self.query(query) if results: - for hostname in results: - if not hostname == event: - await self.emit_event(hostname, "EMAIL_ADDRESS", event, abort_if=self.abort_if) + for email, keyserver in results: + await self.emit_event( + email, + "EMAIL_ADDRESS", + event, + abort_if=self.abort_if, + context=f'{{module}} queried PGP keyserver {keyserver} for "{query}" and found {{event.type}}: {{event.data}}', + ) async def query(self, query): results = set() - for url in self.config.get("search_urls", []): - url = url.replace("", self.helpers.quote(query)) + urls = self.config.get("search_urls", []) + urls = [url.replace("", self.helpers.quote(query)) for url in urls] + async for url, response in self.helpers.request_batch(urls): + keyserver = self.helpers.urlparse(url).netloc response = await self.helpers.request(url) if response is not None: - for email in self.helpers.extract_emails(response.text): + for email in await self.helpers.re.extract_emails(response.text): email = email.lower() if email.endswith(query): - results.add(email) + results.add((email, keyserver)) return results diff --git a/bbot/modules/portscan.py b/bbot/modules/portscan.py new file mode 100644 index 000000000..333cd0d6a --- /dev/null +++ b/bbot/modules/portscan.py @@ -0,0 +1,318 @@ +import json +import ipaddress +from contextlib import suppress +from radixtarget import RadixTarget + +from bbot.modules.base import BaseModule + + +class portscan(BaseModule): + flags = ["active", "portscan", "safe"] + watched_events = ["IP_ADDRESS", "IP_RANGE", "DNS_NAME"] + produced_events = ["OPEN_TCP_PORT"] + meta = { + "description": "Port scan with masscan. By default, scans top 100 ports.", + "created_date": "2024-05-15", + "author": "@TheTechromancer", + } + options = { + "top_ports": 100, + "ports": "", + # ping scan at 600 packets/s ~= private IP space in 8 hours + "rate": 300, + "wait": 5, + "ping_first": False, + "ping_only": False, + "adapter": "", + "adapter_ip": "", + "adapter_mac": "", + "router_mac": "", + } + options_desc = { + "top_ports": "Top ports to scan (default 100) (to override, specify 'ports')", + "ports": "Ports to scan", + "rate": "Rate in packets per second", + "wait": "Seconds to wait for replies after scan is complete", + "ping_first": "Only portscan hosts that reply to pings", + "ping_only": "Ping sweep only, no portscan", + "adapter": 'Manually specify a network interface, such as "eth0" or "tun0". If not specified, the first network interface found with a default gateway will be used.', + "adapter_ip": "Send packets using this IP address. Not needed unless masscan's autodetection fails", + "adapter_mac": "Send packets using this as the source MAC address. Not needed unless masscan's autodetection fails", + "router_mac": "Send packets to this MAC address as the destination. Not needed unless masscan's autodetection fails", + } + deps_common = ["masscan"] + batch_size = 1000000 + _shuffle_incoming_queue = False + + async def setup(self): + self.top_ports = self.config.get("top_ports", 100) + self.rate = self.config.get("rate", 300) + self.wait = self.config.get("wait", 10) + self.ping_first = self.config.get("ping_first", False) + self.ping_only = self.config.get("ping_only", False) + self.ping_scan = self.ping_first or self.ping_only + self.adapter = self.config.get("adapter", "") + self.adapter_ip = self.config.get("adapter_ip", "") + self.adapter_mac = self.config.get("adapter_mac", "") + self.router_mac = self.config.get("router_mac", "") + self.ports = self.config.get("ports", "") + if self.ports: + try: + self.helpers.parse_port_string(self.ports) + except ValueError as e: + return False, f"Error parsing ports: {e}" + # whether we've finished scanning our original scan targets + self.scanned_initial_targets = False + # keeps track of individual scanned IPs and their open ports + # this is necessary because we may encounter more hosts with the same IP + # and we want to avoid scanning them again + self.open_port_cache = {} + # keeps track of which IPs/subnets have already been scanned + self.syn_scanned = self.helpers.make_target(acl_mode=True) + self.ping_scanned = self.helpers.make_target(acl_mode=True) + self.prep_blacklist() + self.helpers.depsinstaller.ensure_root(message="Masscan requires root privileges") + # check if we're set up for IPv6 + self.ipv6_support = True + dry_run_command = self._build_masscan_command(target_file=self.helpers.tempfile(["::1"], pipe=False), wait=0) + ipv6_result = await self.run_process( + dry_run_command, + sudo=True, + _log_stderr=False, + ) + if ipv6_result is None: + return False, "Masscan failed to run" + returncode = getattr(ipv6_result, "returncode", 0) + if returncode and "failed to detect IPv6 address" in ipv6_result.stderr: + self.warning(f"It looks like you are not set up for IPv6. IPv6 targets will not be scanned.") + self.ipv6_support = False + return True + + async def handle_batch(self, *events): + # on our first run, we automatically include all our intial scan targets + if not self.scanned_initial_targets: + self.scanned_initial_targets = True + events = set(events) + events.update( + set([e for e in self.scan.target.seeds.events if e.type in ("DNS_NAME", "IP_ADDRESS", "IP_RANGE")]) + ) + + # ping scan + if self.ping_scan: + ping_targets, ping_correlator = await self.make_targets(events, self.ping_scanned) + ping_events = [] + async for alive_host, _, parent_event in self.masscan(ping_targets, ping_correlator, ping=True): + # port 0 means icmp ping response + ping_event = await self.emit_open_port(alive_host, 0, parent_event) + ping_events.append(ping_event) + syn_targets, syn_correlator = await self.make_targets(ping_events, self.syn_scanned) + else: + syn_targets, syn_correlator = await self.make_targets(events, self.syn_scanned) + + # TCP SYN scan + if not self.ping_only: + async for ip, port, parent_event in self.masscan(syn_targets, syn_correlator): + await self.emit_open_port(ip, port, parent_event) + else: + self.verbose("Only ping sweep was requested, skipping TCP SYN scan") + + async def masscan(self, targets, correlator, ping=False): + scan_type = "ping" if ping else "SYN" + self.verbose(f"Starting masscan {scan_type} scan") + if not targets: + self.verbose("No targets specified, aborting.") + return + + target_file = self.helpers.tempfile(targets, pipe=False) + command = self._build_masscan_command(target_file, ping=ping) + stats_file = self.helpers.tempfile_tail(callback=self.log_masscan_status) + try: + with open(stats_file, "w") as stats_fh: + async for line in self.run_process_live(command, sudo=True, stderr=stats_fh): + for ip, port in self.parse_json_line(line): + parent_events = correlator.search(ip) + # masscan gets the occasional junk result. this is harmless and + # seems to be a side effect of it having its own TCP stack + # see https://github.com/robertdavidgraham/masscan/issues/397 + if parent_events is None: + self.debug(f"Failed to correlate {ip} to targets") + continue + emitted_hosts = set() + for parent_event in parent_events: + if parent_event.type == "DNS_NAME": + host = parent_event.host + else: + host = ip + if host not in emitted_hosts: + yield host, port, parent_event + emitted_hosts.add(host) + finally: + for file in (stats_file, target_file): + file.unlink() + + async def make_targets(self, events, scanned_tracker): + """ + Convert events into a list of targets, skipping ones that have already been scanned + """ + correlator = RadixTarget() + targets = set() + for event in sorted(events, key=lambda e: e._host_size): + # skip events without host + if not event.host: + continue + ips = set() + try: + # first assume it's an ip address / ip range + # False == it's not a hostname + ips.add(ipaddress.ip_network(event.host, strict=False)) + except Exception: + # if it's a hostname, get its IPs from resolved_hosts + for h in event.resolved_hosts: + try: + ips.add(ipaddress.ip_network(h, strict=False)) + except Exception: + continue + + for ip in ips: + # remove IPv6 addresses if we're not scanning IPv6 + if not self.ipv6_support and ip.version == 6: + self.debug(f"Not scanning IPv6 address {ip} because we aren't set up for IPv6") + continue + + # check if we already found open ports on this IP + if event.type != "IP_RANGE": + ip_hash = hash(ip.network_address) + already_found_ports = self.open_port_cache.get(ip_hash, None) + if already_found_ports is not None: + # if so, emit them + for port in already_found_ports: + await self.emit_open_port(event.host, port, event) + + # build a correlation from the IP back to its original parent event + events_set = correlator.search(ip) + if events_set is None: + correlator.insert(ip, {event}) + else: + events_set.add(event) + + # has this IP already been scanned? + if not scanned_tracker.get(ip): + # if not, add it to targets! + scanned_tracker.add(ip) + targets.add(ip) + else: + self.debug(f"Skipping {ip} because it's already been scanned") + + return targets, correlator + + async def emit_open_port(self, ip, port, parent_event): + parent_is_dns_name = parent_event.type == "DNS_NAME" + if parent_is_dns_name: + host = parent_event.host + else: + host = ip + + if port == 0: + event_data = host + event_type = "DNS_NAME" if parent_is_dns_name else "IP_ADDRESS" + scan_type = "ping" + else: + event_data = self.helpers.make_netloc(host, port) + event_type = "OPEN_TCP_PORT" + scan_type = "TCP SYN" + + event = self.make_event( + event_data, + event_type, + parent=parent_event, + context=f"{{module}} executed a {scan_type} scan against {parent_event.data} and found: {{event.type}}: {{event.data}}", + ) + await self.emit_event(event) + return event + + def parse_json_line(self, line): + try: + j = json.loads(line) + except Exception: + return + ip = j.get("ip", "") + if not ip: + return + ip = self.helpers.make_ip_type(ip) + ip_hash = hash(ip) + ports = j.get("ports", []) + if not ports: + return + for p in ports: + proto = p.get("proto", "") + port_number = p.get("port", 0) + try: + self.open_port_cache[ip_hash].add(port_number) + except KeyError: + self.open_port_cache[ip_hash] = {port_number} + if proto == "" or port_number == "": + continue + yield ip, port_number + + def prep_blacklist(self): + exclude = [] + for t in self.scan.blacklist: + t = self.helpers.make_ip_type(t.data) + if not isinstance(t, str): + if self.helpers.is_ip(t): + exclude.append(str(ipaddress.ip_network(t))) + else: + exclude.append(str(t)) + if not exclude: + exclude = ["255.255.255.255/32"] + self.exclude_file = self.helpers.tempfile(exclude, pipe=False) + + def _build_masscan_command(self, target_file=None, ping=False, dry_run=False, wait=None): + if wait is None: + wait = self.wait + command = ( + "masscan", + "--excludefile", + str(self.exclude_file), + "--rate", + self.rate, + "--wait", + wait, + "--open-only", + "-oJ", + "-", + ) + if target_file is not None: + command += ("-iL", str(target_file)) + if dry_run: + command += ("-p1", "--wait", "0") + else: + if self.adapter: + command += ("--adapter", self.adapter) + if self.adapter_ip: + command += ("--adapter-ip", self.adapter_ip) + if self.adapter_mac: + command += ("--adapter-mac", self.adapter_mac) + if self.router_mac: + command += ("--router-mac", self.router_mac) + if ping: + command += ("--ping",) + else: + if self.ports: + command += ("-p", self.ports) + else: + command += ("-p", self.helpers.top_tcp_ports(self.top_ports, as_string=True)) + return command + + def log_masscan_status(self, s): + if "FAIL" in s: + self.warning(s) + self.warning( + f'Masscan failed to detect interface. Recommend passing "adapter_ip", "adapter_mac", and "router_mac" config options to portscan module.' + ) + else: + self.verbose(s) + + async def cleanup(self): + with suppress(Exception): + self.exclude_file.unlink() diff --git a/bbot/modules/postman.py b/bbot/modules/postman.py index bfdc2f90d..e736bec1a 100644 --- a/bbot/modules/postman.py +++ b/bbot/modules/postman.py @@ -4,7 +4,7 @@ class postman(subdomain_enum): watched_events = ["DNS_NAME"] produced_events = ["URL_UNVERIFIED"] - flags = ["passive", "subdomain-enum", "safe"] + flags = ["passive", "subdomain-enum", "safe", "code-enum"] meta = { "description": "Query Postman's API for related workspaces, collections, requests", "created_date": "2023-12-23", @@ -29,8 +29,8 @@ class postman(subdomain_enum): async def handle_event(self, event): query = self.make_query(event) self.verbose(f"Searching for any postman workspaces, collections, requests belonging to {query}") - for url in await self.query(query): - await self.emit_event(url, "URL_UNVERIFIED", source=event, tags="httpx-safe") + for url, context in await self.query(query): + await self.emit_event(url, "URL_UNVERIFIED", parent=event, tags="httpx-safe", context=context) async def query(self, query): interesting_urls = [] @@ -78,16 +78,46 @@ async def query(self, query): tldextract = self.helpers.tldextract(query) if tldextract.domain.lower() in name.lower(): self.verbose(f"Discovered workspace {name} ({id})") - interesting_urls.append(f"{self.base_url}/workspace/{id}") + workspace_url = f"{self.base_url}/workspace/{id}" + interesting_urls.append( + ( + workspace_url, + f'{{module}} searched postman.com for "{query}" and found matching workspace "{name}" at {{event.type}}: {workspace_url}', + ) + ) environments, collections = await self.search_workspace(id) - interesting_urls.append(f"{self.base_url}/workspace/{id}/globals") + globals_url = f"{self.base_url}/workspace/{id}/globals" + interesting_urls.append( + ( + globals_url, + f'{{module}} searched postman.com for "{query}", found matching workspace "{name}" at {workspace_url}, and found globals at {{event.type}}: {globals_url}', + ) + ) for e_id in environments: - interesting_urls.append(f"{self.base_url}/environment/{e_id}") + env_url = f"{self.base_url}/environment/{e_id}" + interesting_urls.append( + ( + env_url, + f'{{module}} searched postman.com for "{query}", found matching workspace "{name}" at {workspace_url}, enumerated environments, and found {{event.type}}: {env_url}', + ) + ) for c_id in collections: - interesting_urls.append(f"{self.base_url}/collection/{c_id}") + collection_url = f"{self.base_url}/collection/{c_id}" + interesting_urls.append( + ( + collection_url, + f'{{module}} searched postman.com for "{query}", found matching workspace "{name}" at {workspace_url}, enumerated collections, and found {{event.type}}: {collection_url}', + ) + ) requests = await self.search_collections(id) for r_id in requests: - interesting_urls.append(f"{self.base_url}/request/{r_id}") + request_url = f"{self.base_url}/request/{r_id}" + interesting_urls.append( + ( + request_url, + f'{{module}} searched postman.com for "{query}", found matching workspace "{name}" at {workspace_url}, enumerated requests, and found {{event.type}}: {request_url}', + ) + ) else: self.verbose(f"Skipping workspace {name} ({id}) as it does not appear to be in scope") return interesting_urls diff --git a/bbot/modules/rapiddns.py b/bbot/modules/rapiddns.py index 55cca4dfe..934beb829 100644 --- a/bbot/modules/rapiddns.py +++ b/bbot/modules/rapiddns.py @@ -15,7 +15,7 @@ class rapiddns(subdomain_enum): async def request_url(self, query): url = f"{self.base_url}/subdomain/{self.helpers.quote(query)}?full=1#result" - response = await self.request_with_fail_count(url) + response = await self.request_with_fail_count(url, timeout=self.http_timeout + 10) return response def parse_results(self, r, query): diff --git a/bbot/modules/report/affiliates.py b/bbot/modules/report/affiliates.py index 73b2867e2..a67c66550 100644 --- a/bbot/modules/report/affiliates.py +++ b/bbot/modules/report/affiliates.py @@ -28,7 +28,7 @@ async def report(self): count = stats["count"] weight = stats["weight"] table.append([domain, f"{weight:.2f}", f"{count:,}"]) - self.log_table(table, header, table_name="affiliates") + self.log_table(table, header, table_name="affiliates", max_log_entries=50) def add_affiliate(self, event): if event.scope_distance > 0 and event.host and isinstance(event.host, str): diff --git a/bbot/modules/report/asn.py b/bbot/modules/report/asn.py index 32590c0dd..61e51a725 100644 --- a/bbot/modules/report/asn.py +++ b/bbot/modules/report/asn.py @@ -38,19 +38,31 @@ async def filter_event(self, event): async def handle_event(self, event): host = event.host if self.cache_get(host) == False: - asns = await self.get_asn(host) + 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", source=event) + 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) + 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", source=asn_event) + 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): asn_data = sorted(self.asn_cache.items(), key=lambda x: self.asn_counts[x[0]], reverse=True) @@ -104,7 +116,7 @@ async def get_asn(self, ip, retries=1): self.sources.append(self.sources.pop(i)) self.verbose(f"Failed to contact {source}, retrying") continue - return res + return res, source self.warning(f"Error retrieving ASN for {ip}") return [], "" @@ -153,7 +165,7 @@ async def get_asn_metadata_ripe(self, asn_number): for item in record: key = item.get("key", "") value = item.get("value", "") - for email in self.helpers.extract_emails(value): + for email in await self.helpers.re.extract_emails(value): emails.add(email.lower()) if not key: continue diff --git a/bbot/modules/robots.py b/bbot/modules/robots.py index 8faf59664..fc7692051 100644 --- a/bbot/modules/robots.py +++ b/bbot/modules/robots.py @@ -4,7 +4,7 @@ class robots(BaseModule): watched_events = ["URL"] produced_events = ["URL_UNVERIFIED"] - flags = ["active", "safe", "web-basic", "web-thorough"] + flags = ["active", "safe", "web-basic"] meta = {"description": "Look for and parse robots.txt", "created_date": "2023-02-01", "author": "@liquidsec"} options = {"include_sitemap": False, "include_allow": True, "include_disallow": True} @@ -21,7 +21,7 @@ async def setup(self): return True async def handle_event(self, event): - host = f"{event.parsed.scheme}://{event.parsed.netloc}/" + host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/" result = None url = f"{host}robots.txt" result = await self.helpers.request(url) @@ -44,8 +44,10 @@ async def handle_event(self, event): unverified_url = split_l[1] else: continue - - tags = [] - if self.helpers.is_spider_danger(event, unverified_url): - tags.append("spider-danger") - await self.emit_event(unverified_url, "URL_UNVERIFIED", source=event, tags=tags) + await self.emit_event( + unverified_url, + "URL_UNVERIFIED", + parent=event, + tags=["spider-danger"], + context=f"{{module}} found robots.txt at {url} and extracted {{event.type}}: {{event.data}}", + ) diff --git a/bbot/modules/secretsdb.py b/bbot/modules/secretsdb.py index 62246e1cc..2d70e538d 100644 --- a/bbot/modules/secretsdb.py +++ b/bbot/modules/secretsdb.py @@ -7,7 +7,7 @@ class secretsdb(BaseModule): watched_events = ["HTTP_RESPONSE"] produced_events = ["FINDING"] - flags = ["active", "safe", "web-basic", "web-thorough"] + flags = ["active", "safe", "web-basic"] meta = { "description": "Detect common secrets with secrets-patterns-db", "created_date": "2023-03-17", @@ -50,18 +50,19 @@ async def setup(self): async def handle_event(self, event): resp_body = event.data.get("body", "") resp_headers = event.data.get("raw_header", "") - all_matches = await self.scan.run_in_executor(self.search_data, resp_body, resp_headers) + all_matches = await self.helpers.run_in_executor(self.search_data, resp_body, resp_headers) for matches, name in all_matches: matches = [m.string[m.start() : m.end()] for m in matches] description = f"Possible secret ({name}): {matches}" event_data = {"host": str(event.host), "description": description} - parsed_url = getattr(event, "parsed", None) + parsed_url = getattr(event, "parsed_url", None) if parsed_url: event_data["url"] = parsed_url.geturl() await self.emit_event( event_data, "FINDING", - source=event, + parent=event, + context=f"{{module}} searched HTTP response and found {{event.type}}: {description}", ) def search_data(self, resp_body, resp_headers): diff --git a/bbot/modules/sitedossier.py b/bbot/modules/sitedossier.py index da11ac82f..fe9015027 100644 --- a/bbot/modules/sitedossier.py +++ b/bbot/modules/sitedossier.py @@ -23,7 +23,13 @@ async def handle_event(self, event): self.verbose(e) continue if hostname and hostname.endswith(f".{query}") and not hostname == event.data: - await self.emit_event(hostname, "DNS_NAME", event, abort_if=self.abort_if) + await self.emit_event( + hostname, + "DNS_NAME", + event, + abort_if=self.abort_if, + context=f'{{module}} searched sitedossier.com for "{query}" and found {{event.type}}: {{event.data}}', + ) async def query(self, query, parse_fn=None, request_fn=None): results = set() @@ -40,12 +46,11 @@ async def query(self, query, parse_fn=None, request_fn=None): if response.status_code == 302: self.verbose("Hit rate limit captcha") break - for regex in self.scan.dns_regexes: - for match in regex.finditer(response.text): - hostname = match.group().lower() - if hostname and hostname not in results: - results.add(hostname) - yield hostname + for match in await self.helpers.re.finditer_multi(self.scan.dns_regexes, response.text): + hostname = match.group().lower() + if hostname and hostname not in results: + results.add(hostname) + yield hostname if '= last_page: - break + domain_ids = await self.helpers.re.findall(self.next_page_regex, r.text) + if domain_ids: + domain_id = domain_ids[0] + for page in range(2, 22): + r2 = await self.request_with_fail_count(f"{self.base_url}/domain/{domain_id}?p={page}") + if not r2: + continue + responses.append(r2) + pages = re.findall(r"/domain/" + domain_id + r"\?p=(\d+)", r2.text) + if not pages: + break + last_page = max([int(p) for p in pages]) + if page >= last_page: + break + + for i, r in enumerate(responses): + for email in await self.helpers.re.extract_emails(r.text): + await self.emit_event( + email, + "EMAIL_ADDRESS", + parent=event, + context=f'{{module}} searched skymem.info for "{query}" and found {{event.type}} on page {i+1}: {{event.data}}', + ) diff --git a/bbot/modules/smuggler.py b/bbot/modules/smuggler.py index 6a3c5a85a..357fec188 100644 --- a/bbot/modules/smuggler.py +++ b/bbot/modules/smuggler.py @@ -42,5 +42,6 @@ async def handle_event(self, event): await self.emit_event( {"host": str(event.host), "url": event.data, "description": description}, "FINDING", - source=event, + parent=event, + context=f"{{module}} scanned {event.data} and found HTTP smuggling ({{event.type}}): {text}", ) diff --git a/bbot/modules/social.py b/bbot/modules/social.py index 4dbf05948..b80f6c18a 100644 --- a/bbot/modules/social.py +++ b/bbot/modules/social.py @@ -41,10 +41,14 @@ async def handle_event(self, event): if not case_sensitive: url = url.lower() profile_name = profile_name.lower() + url = f"https://{url}" social_event = self.make_event( - {"platform": platform, "url": f"https://{url}", "profile_name": profile_name}, + {"platform": platform, "url": url, "profile_name": profile_name}, "SOCIAL", - source=event, + parent=event, ) social_event.scope_distance = event.scope_distance - await self.emit_event(social_event) + await self.emit_event( + social_event, + context=f"{{module}} detected {platform} {{event.type}} at {url}", + ) diff --git a/bbot/modules/sslcert.py b/bbot/modules/sslcert.py index 13b868feb..814068f03 100644 --- a/bbot/modules/sslcert.py +++ b/bbot/modules/sslcert.py @@ -2,15 +2,16 @@ from OpenSSL import crypto from contextlib import suppress +from bbot.errors import ValidationError from bbot.modules.base import BaseModule -from bbot.core.errors import ValidationError from bbot.core.helpers.async_helpers import NamedLock +from bbot.core.helpers.web.ssl_context import ssl_context_noverify class sslcert(BaseModule): watched_events = ["OPEN_TCP_PORT"] produced_events = ["DNS_NAME", "EMAIL_ADDRESS"] - flags = ["affiliates", "subdomain-enum", "email-enum", "active", "safe", "web-basic", "web-thorough"] + flags = ["affiliates", "subdomain-enum", "email-enum", "active", "safe", "web-basic"] meta = { "description": "Visit open ports and retrieve SSL certificates", "created_date": "2022-03-30", @@ -20,7 +21,7 @@ class sslcert(BaseModule): options_desc = {"timeout": "Socket connect timeout in seconds", "skip_non_ssl": "Don't try common non-SSL ports"} deps_apt = ["openssl"] deps_pip = ["pyOpenSSL~=24.0.0"] - _max_event_handlers = 25 + _module_threads = 25 scope_distance_modifier = 1 _priority = 2 @@ -31,7 +32,7 @@ async def setup(self): # sometimes we run into a server with A LOT of SANs # these are usually stupid and useless, so we abort based on a different threshold - # depending on whether the source event is in scope + # depending on whether the parent event is in scope self.in_scope_abort_threshold = 50 self.out_of_scope_abort_threshold = 10 @@ -79,16 +80,25 @@ async def handle_event(self, event): if event_data is not None and event_data != event: self.debug(f"Discovered new {event_type} via SSL certificate parsing: [{event_data}]") try: - ssl_event = self.make_event(event_data, event_type, source=event, raise_error=True) + ssl_event = self.make_event(event_data, event_type, parent=event, raise_error=True) + parent_event = ssl_event.get_parent() + if parent_event.scope_distance == 0: + tags = ["affiliate"] + else: + tags = None if ssl_event: - await self.emit_event(ssl_event, on_success_callback=self.on_success_callback) + await self.emit_event( + ssl_event, + tags=tags, + context=f"{{module}} parsed SSL certificate at {event.data} and found {{event.type}}: {{event.data}}", + ) except ValidationError as e: self.hugeinfo(f'Malformed {event_type} "{event_data}" at {event.data}') self.debug(f"Invalid data at {host}:{port}: {e}") def on_success_callback(self, event): - source_scope_distance = event.get_source().scope_distance - if source_scope_distance == 0 and event.scope_distance > 0: + parent_scope_distance = event.get_parent().scope_distance + if parent_scope_distance == 0 and event.scope_distance > 0: event.add_tag("affiliate") async def visit_host(self, host, port): @@ -106,17 +116,12 @@ async def visit_host(self, host, port): host = str(host) - # Create an SSL context - try: - ssl_context = self.helpers.ssl_context_noverify() - except Exception as e: - self.warning(f"Error creating SSL context: {e}") - return [], [], (host, port) - # Connect to the host try: transport, _ = await asyncio.wait_for( - self.scan._loop.create_connection(lambda: asyncio.Protocol(), host, port, ssl=ssl_context), + self.helpers.loop.create_connection( + lambda: asyncio.Protocol(), host, port, ssl=ssl_context_noverify + ), timeout=self.timeout, ) except asyncio.TimeoutError: diff --git a/bbot/modules/telerik.py b/bbot/modules/telerik.py index 64a6437c3..1857a4f5a 100644 --- a/bbot/modules/telerik.py +++ b/bbot/modules/telerik.py @@ -1,4 +1,3 @@ -import asyncio from sys import executable from urllib.parse import urlparse @@ -161,7 +160,7 @@ class telerik(BaseModule): }, ] - _max_event_handlers = 5 + _module_threads = 5 def _incoming_dedup_hash(self, event): if event.type == "URL": @@ -169,10 +168,6 @@ def _incoming_dedup_hash(self, event): else: return hash(event.data["url"]) - async def setup(self): - self.timeout = self.scan.config.get("httpx_timeout", 5) - return True - async def handle_event(self, event): if event.type == "URL": webresource = "Telerik.Web.UI.WebResource.axd?type=rau" @@ -219,6 +214,7 @@ async def handle_event(self, event): {"host": str(event.host), "url": f"{event.data}{webresource}", "description": description}, "FINDING", event, + context=f"{{module}} scanned {event.data} and identified {{event.type}}: Telerik RAU AXD Handler", ) if self.config.get("exploit_RAU_crypto") == True: hostname = urlparse(event.data).netloc @@ -250,34 +246,29 @@ async def handle_event(self, event): }, "VULNERABILITY", event, + context=f"{{module}} scanned {event.data} and identified critical {{event.type}}: {description}", ) break - tasks = [] + urls = {} for dh in self.DialogHandlerUrls: - tasks.append(self.helpers.create_task(self.test_detector(event.data, f"{dh}?dp=1"))) + url = self.create_url(event.data, f"{dh}?dp=1") + urls[url] = dh + gen = self.helpers.request_batch(list(urls)) fail_count = 0 - gen = self.helpers.as_completed(tasks) - async for task in gen: - try: - result, dh = await task - except asyncio.CancelledError: - continue - + async for url, response in gen: # cancel if we run into timeouts etc. - if result is None: + if response is None: fail_count += 1 # tolerate some random errors if fail_count < 2: continue self.debug(f"Cancelling run against {event.data} due to failed request") - await self.helpers.cancel_tasks(tasks) await gen.aclose() else: - if "Cannot deserialize dialog parameters" in result.text: - await self.helpers.cancel_tasks(tasks) + if "Cannot deserialize dialog parameters" in response.text: self.debug(f"Detected Telerik UI instance ({dh})") description = f"Telerik DialogHandler detected" await self.emit_event( @@ -288,8 +279,6 @@ async def handle_event(self, event): # Once we have a match we need to stop, because the basic handler (Telerik.Web.UI.DialogHandler.aspx) usually works with a path wildcard await gen.aclose() - await self.helpers.cancel_tasks(tasks) - spellcheckhandler = "Telerik.Web.UI.SpellCheckHandler.axd" result, _ = await self.test_detector(event.data, spellcheckhandler) status_code = getattr(result, "status_code", 0) @@ -310,6 +299,7 @@ async def handle_event(self, event): }, "FINDING", event, + context=f"{{module}} scanned {event.data} and identified {{event.type}}: Telerik SpellCheckHandler", ) chartimagehandler = "ChartImage.axd?ImageName=bqYXJAqm315eEd6b%2bY4%2bGqZpe7a1kY0e89gfXli%2bjFw%3d" @@ -328,41 +318,49 @@ async def handle_event(self, event): }, "FINDING", event, + context=f"{{module}} scanned {event.data} and identified {{event.type}}: Telerik ChartImage AXD Handler", ) elif event.type == "HTTP_RESPONSE": resp_body = event.data.get("body", None) + url = event.data["url"] if resp_body: if '":{"SerializedParameters":"' in resp_body: await self.emit_event( { "host": str(event.host), - "url": event.data["url"], + "url": url, "description": "Telerik DialogHandler [SerializedParameters] Detected in HTTP Response", }, "FINDING", event, + context=f"{{module}} searched HTTP_RESPONSE and identified {{event.type}}: Telerik ChartImage AXD Handler", ) elif '"_serializedConfiguration":"' in resp_body: await self.emit_event( { "host": str(event.host), - "url": event.data["url"], + "url": url, "description": "Telerik AsyncUpload [serializedConfiguration] Detected in HTTP Response", }, "FINDING", event, + context=f"{{module}} searched HTTP_RESPONSE and identified {{event.type}}: Telerik AsyncUpload", ) # Check for RAD Controls in URL - async def test_detector(self, baseurl, detector): - result = None - if "/" != baseurl[-1]: + def create_url(self, baseurl, detector): + if not baseurl.endswith("/"): url = f"{baseurl}/{detector}" else: url = f"{baseurl}{detector}" - result = await self.helpers.request(url, timeout=self.timeout) + return url + + async def test_detector(self, baseurl, detector): + result = None + url = self.create_url(baseurl, detector) + result = await self.helpers.request(url, timeout=self.scan.httpx_timeout) return result, detector async def filter_event(self, event): diff --git a/bbot/modules/templates/bucket.py b/bbot/modules/templates/bucket.py index 6ae042715..f5a10387f 100644 --- a/bbot/modules/templates/bucket.py +++ b/bbot/modules/templates/bucket.py @@ -4,7 +4,7 @@ class bucket_template(BaseModule): watched_events = ["DNS_NAME", "STORAGE_BUCKET"] produced_events = ["STORAGE_BUCKET", "FINDING"] - flags = ["active", "safe", "cloud-enum", "web-basic", "web-thorough"] + flags = ["active", "safe", "cloud-enum", "web-basic"] options = {"permutations": False} options_desc = { "permutations": "Whether to try permutations", @@ -19,7 +19,7 @@ class bucket_template(BaseModule): async def setup(self): self.buckets_tried = set() - self.cloud_helper = self.helpers.cloud[self.cloud_helper_name] + self.cloud_helper = self.helpers.cloud.providers[self.cloud_helper_name] self.permutations = self.config.get("permutations", False) return True @@ -52,8 +52,14 @@ async def handle_dns_name(self, event): for d in self.delimiters: bucket_name = d.join(split) buckets.add(bucket_name) - async for bucket_name, url, tags in self.brute_buckets(buckets, permutations=self.permutations): - await self.emit_event({"name": bucket_name, "url": url}, "STORAGE_BUCKET", source=event, tags=tags) + async for bucket_name, url, tags, num_buckets in self.brute_buckets(buckets, permutations=self.permutations): + await self.emit_event( + {"name": bucket_name, "url": url}, + "STORAGE_BUCKET", + parent=event, + tags=tags, + context=f"{{module}} tried {num_buckets:,} bucket variations of {event.data} and found {{event.type}} at {url}", + ) async def handle_storage_bucket(self, event): url = event.data["url"] @@ -62,12 +68,24 @@ async def handle_storage_bucket(self, event): description, tags = await self._check_bucket_open(bucket_name, url) if description: event_data = {"host": event.host, "url": url, "description": description} - await self.emit_event(event_data, "FINDING", source=event, tags=tags) - - async for bucket_name, url, tags in self.brute_buckets( + await self.emit_event( + event_data, + "FINDING", + parent=event, + tags=tags, + context=f"{{module}} scanned {event.type} and identified {{event.type}}: {description}", + ) + + async for bucket_name, new_url, tags, num_buckets in self.brute_buckets( [bucket_name], permutations=self.permutations, omit_base=True ): - await self.emit_event({"name": bucket_name, "url": url}, "STORAGE_BUCKET", source=event, tags=tags) + await self.emit_event( + {"name": bucket_name, "url": new_url}, + "STORAGE_BUCKET", + parent=event, + tags=tags, + context=f"{{module}} tried {num_buckets:,} variations of {url} and found {{event.type}} at {new_url}", + ) async def brute_buckets(self, buckets, permutations=False, omit_base=False): buckets = set(buckets) @@ -80,27 +98,33 @@ async def brute_buckets(self, buckets, permutations=False, omit_base=False): if omit_base: new_buckets = new_buckets - buckets new_buckets = [b for b in new_buckets if self.valid_bucket_name(b)] - tasks = [] + num_buckets = len(new_buckets) + bucket_urls_kwargs = [] for base_domain in self.base_domains: for region in self.regions: for bucket_name in new_buckets: - url = self.build_url(bucket_name, base_domain, region) - tasks.append(self._check_bucket_exists(bucket_name, url)) - async for task in self.helpers.as_completed(tasks): - existent_bucket, tags, bucket_name, url = await task + url, kwargs = self.build_bucket_request(bucket_name, base_domain, region) + bucket_urls_kwargs.append((url, kwargs, (bucket_name, base_domain, region))) + async for url, kwargs, (bucket_name, base_domain, region), response in self.helpers.request_custom_batch( + bucket_urls_kwargs + ): + existent_bucket, tags = self._check_bucket_exists(bucket_name, response) if existent_bucket: - yield bucket_name, url, tags + yield bucket_name, url, tags, num_buckets - async def _check_bucket_exists(self, bucket_name, url): + def build_bucket_request(self, bucket_name, base_domain, region): + url = self.build_url(bucket_name, base_domain, region) + return url, {} + + def _check_bucket_exists(self, bucket_name, response): self.debug(f'Checking if bucket exists: "{bucket_name}"') - return await self.check_bucket_exists(bucket_name, url) + return self.check_bucket_exists(bucket_name, response) - async def check_bucket_exists(self, bucket_name, url): - response = await self.helpers.request(url) + def check_bucket_exists(self, bucket_name, response): tags = self.gen_tags_exists(response) status_code = getattr(response, "status_code", 404) existent_bucket = status_code != 404 - return (existent_bucket, tags, bucket_name, url) + return (existent_bucket, tags) async def _check_bucket_open(self, bucket_name, url): self.debug(f'Checking if bucket is misconfigured: "{bucket_name}"') diff --git a/bbot/modules/templates/github.py b/bbot/modules/templates/github.py index fcfeb0934..4169647ce 100644 --- a/bbot/modules/templates/github.py +++ b/bbot/modules/templates/github.py @@ -1,7 +1,7 @@ -from bbot.modules.templates.subdomain_enum import subdomain_enum +from bbot.modules.base import BaseModule -class github(subdomain_enum): +class github(BaseModule): """ A template module for use of the GitHub API Inherited by several other github modules. diff --git a/bbot/modules/templates/portscanner.py b/bbot/modules/templates/portscanner.py deleted file mode 100644 index 5d1662d81..000000000 --- a/bbot/modules/templates/portscanner.py +++ /dev/null @@ -1,55 +0,0 @@ -import ipaddress - -from bbot.modules.base import BaseModule - - -class portscanner(BaseModule): - """ - A portscanner containing useful methods for nmap, masscan, etc. - """ - - async def setup(self): - self.ip_ranges = [e.host for e in self.scan.target.events if e.type == "IP_RANGE"] - exclude, invalid_exclude = self._build_targets(self.scan.blacklist) - if not exclude: - exclude = ["255.255.255.255/32"] - self.exclude_file = self.helpers.tempfile(exclude, pipe=False) - if invalid_exclude > 0: - self.warning( - f"Port scanner can only accept IP addresses or IP ranges as blacklist ({invalid_exclude:,} blacklisted were hostnames)" - ) - return True - - async def filter_event(self, event): - """ - The purpose of this filter_event is to decide whether we should accept individual IP_ADDRESS - events that reside inside our target subnets (IP_RANGE), if any. - - This prevents scanning the same IP twice. - """ - # if we are emitting hosts from a previous asset_inventory, this is a special case - # in this case we want to accept the individual IPs even if they overlap with our target ranges - asset_inventory_module = self.scan.modules.get("asset_inventory", None) - asset_inventory_config = getattr(asset_inventory_module, "config", {}) - asset_inventory_use_previous = asset_inventory_config.get("use_previous", False) - if event.type == "IP_ADDRESS" and not asset_inventory_use_previous: - for net in self.helpers.ip_network_parents(event.data, include_self=True): - if net in self.ip_ranges: - return False, f"skipping {event.host} because it is already included in {net}" - elif event.type == "IP_RANGE" and asset_inventory_use_previous: - return False, f"skipping IP_RANGE {event.host} because asset_inventory.use_previous=True" - return True - - def _build_targets(self, target): - invalid_targets = 0 - targets = [] - for t in target: - t = self.helpers.make_ip_type(t.data) - if isinstance(t, str): - invalid_targets += 1 - else: - if self.helpers.is_ip(t): - targets.append(str(ipaddress.ip_network(t))) - else: - targets.append(str(t)) - return targets, invalid_targets diff --git a/bbot/modules/templates/subdomain_enum.py b/bbot/modules/templates/subdomain_enum.py index 790b35515..95c7995d3 100644 --- a/bbot/modules/templates/subdomain_enum.py +++ b/bbot/modules/templates/subdomain_enum.py @@ -16,15 +16,29 @@ class subdomain_enum(BaseModule): # set module error state after this many failed requests in a row abort_after_failures = 5 + # whether to reject wildcard DNS_NAMEs reject_wildcards = "strict" - # this helps combat rate limiting by ensuring that a query doesn't execute + + # set qsize to 1. this helps combat rate limiting by ensuring that a query doesn't execute # until the queue is ready to receive its results _qsize = 1 - async def setup(self): - self.processed = set() - return True + # how to deduplicate incoming events + # options: + # "highest_parent": dedupe by highest parent (highest parent of www.api.test.evilcorp.com is evilcorp.com) + # "lowest_parent": dedupe by lowest parent (lowest parent of www.api.test.evilcorp.com is api.test.evilcorp.com) + dedup_strategy = "highest_parent" + + @property + def source_pretty_name(self): + return f"{self.__class__.__name__} API" + + def _incoming_dedup_hash(self, event): + """ + Determines the criteria for what is considered to be a duplicate event if `accept_dupes` is False. + """ + return hash(self.make_query(event)), f"dedup_strategy={self.dedup_strategy}" async def handle_event(self, event): query = self.make_query(event) @@ -38,18 +52,36 @@ async def handle_event(self, event): self.verbose(e) continue if hostname and hostname.endswith(f".{query}") and not hostname == event.data: - await self.emit_event(hostname, "DNS_NAME", event, abort_if=self.abort_if) + await self.emit_event( + hostname, + "DNS_NAME", + event, + abort_if=self.abort_if, + context=f'{{module}} searched {self.source_pretty_name} for "{query}" and found {{event.type}}: {{event.data}}', + ) async def request_url(self, query): url = f"{self.base_url}/subdomains/{self.helpers.quote(query)}" return await self.request_with_fail_count(url) def make_query(self, event): - if "target" in event.tags: - query = str(event.data) + query = event.data + parents = list(self.helpers.domain_parents(event.data)) + if self.dedup_strategy == "highest_parent": + parents = list(reversed(parents)) + elif self.dedup_strategy == "lowest_parent": + pass else: - query = self.helpers.parent_domain(event.data).lower() - return ".".join([s for s in query.split(".") if s != "_wildcard"]) + raise ValueError('self.dedup_strategy attribute must be set to either "highest_parent" or "lowest_parent"') + for p in parents: + if self.scan.in_scope(p): + query = p + break + try: + return ".".join([s for s in query.split(".") if s != "_wildcard"]) + except Exception: + self.critical(query) + raise def parse_results(self, r, query=None): json = r.json() @@ -74,7 +106,7 @@ async def query(self, query, parse_fn=None, request_fn=None): self.info( f'Error parsing results for query "{query}" (status code {response.status_code})', trace=True ) - self.log.trace(response.text) + self.log.trace(repr(response.text)) else: self.info(f'Error parsing results for "{query}": {e}', trace=True) return @@ -92,20 +124,6 @@ async def _is_wildcard(self, query): return False async def filter_event(self, event): - """ - This filter_event is used across many modules - """ - query = self.make_query(event) - # reject if already processed - if self.already_processed(query): - return False, "Event was already processed" - eligible, reason = await self.eligible_for_enumeration(event) - if eligible: - self.processed.add(hash(query)) - return True, reason - return False, reason - - async def eligible_for_enumeration(self, event): query = self.make_query(event) # check if wildcard is_wildcard = await self._is_wildcard(query) @@ -128,12 +146,6 @@ async def eligible_for_enumeration(self, event): return False, "Event is both a cloud resource and a wildcard domain" return True, "" - def already_processed(self, hostname): - for parent in self.helpers.domain_parents(hostname, include_self=True): - if hash(parent) in self.processed: - return True - return False - async def abort_if(self, event): # this helps weed out unwanted results when scanning IP_RANGES and wildcard domains if "in-scope" not in event.tags: diff --git a/bbot/modules/trufflehog.py b/bbot/modules/trufflehog.py index b3a036297..e0ff0fd2a 100644 --- a/bbot/modules/trufflehog.py +++ b/bbot/modules/trufflehog.py @@ -5,7 +5,7 @@ class trufflehog(BaseModule): watched_events = ["FILESYSTEM"] produced_events = ["FINDING", "VULNERABILITY"] - flags = ["passive", "safe"] + flags = ["passive", "safe", "code-enum"] meta = { "description": "TruffleHog is a tool for finding credentials", "created_date": "2024-03-12", @@ -57,19 +57,29 @@ async def handle_event(self, event): data = { "severity": "High", "description": f"Verified Secret Found. Detector Type: [{detector_name}] Decoder Type: [{decoder_name}] Secret: [{raw_result}] Details: [{source_metadata}]", - "host": str(event.source.host), + "host": str(event.parent.host), } if description: data["description"] += f" Description: [{description}]" - await self.emit_event(data, "VULNERABILITY", event) + await self.emit_event( + data, + "VULNERABILITY", + event, + context=f'{{module}} searched {event.type} using "{module}" method and found verified secret ({{event.type}}): {raw_result}', + ) else: data = { "description": f"Potential Secret Found. Detector Type: [{detector_name}] Decoder Type: [{decoder_name}] Secret: [{raw_result}] Details: [{source_metadata}]", - "host": str(event.source.host), + "host": str(event.parent.host), } if description: data["description"] += f" Description: [{description}]" - await self.emit_event(data, "FINDING", event) + await self.emit_event( + data, + "FINDING", + event, + context=f'{{module}} searched {event.type} using "{module}" method and found possible secret ({{event.type}}): {raw_result}', + ) async def execute_trufflehog(self, module, path): command = [ diff --git a/bbot/modules/unstructured.py b/bbot/modules/unstructured.py index 96d384b49..06118a348 100644 --- a/bbot/modules/unstructured.py +++ b/bbot/modules/unstructured.py @@ -92,18 +92,18 @@ async def handle_event(self, event): if not any(ignored_folder in str(file_path) for ignored_folder in self.ignored_folders): if any(file_path.name.endswith(f".{ext}") for ext in self.extensions): file_event = self.make_event( - {"path": str(file_path)}, "FILESYSTEM", tags=["parsed_folder", "file"], source=event + {"path": str(file_path)}, "FILESYSTEM", tags=["parsed_folder", "file"], parent=event ) file_event.scope_distance = event.scope_distance await self.emit_event(file_event) elif "file" in event.tags: file_path = event.data["path"] - content = await self.scan.run_in_executor_mp(extract_text, file_path) + content = await self.scan.helpers.run_in_executor_mp(extract_text, file_path) if content: raw_text_event = self.make_event( content, "RAW_TEXT", - source=event, + parent=event, ) await self.emit_event(raw_text_event) diff --git a/bbot/modules/url_manipulation.py b/bbot/modules/url_manipulation.py index 209e51a11..ef7cfe7f3 100644 --- a/bbot/modules/url_manipulation.py +++ b/bbot/modules/url_manipulation.py @@ -1,5 +1,5 @@ +from bbot.errors import HttpCompareError from bbot.modules.base import BaseModule -from bbot.core.errors import HttpCompareError class url_manipulation(BaseModule): @@ -81,7 +81,8 @@ async def handle_event(self, event): await self.emit_event( {"description": description, "host": str(event.host), "url": event.data}, "FINDING", - source=event, + parent=event, + context=f"{{module}} probed {event.data} and identified {{event.type}}: {description}", ) else: self.debug(f"Status code changed to {str(subject_response.status_code)}, ignoring") @@ -98,10 +99,10 @@ async def filter_event(self, event): def format_signature(self, sig, event): if sig[2] == True: - cleaned_path = event.parsed.path.strip("/") + cleaned_path = event.parsed_url.path.strip("/") else: - cleaned_path = event.parsed.path.lstrip("/") + cleaned_path = event.parsed_url.path.lstrip("/") - kwargs = {"scheme": event.parsed.scheme, "netloc": event.parsed.netloc, "path": cleaned_path} + kwargs = {"scheme": event.parsed_url.scheme, "netloc": event.parsed_url.netloc, "path": cleaned_path} formatted_url = sig[1].format(**kwargs) return (sig[0], formatted_url) diff --git a/bbot/modules/urlscan.py b/bbot/modules/urlscan.py index d9e6cbb43..8b9b53bc8 100644 --- a/bbot/modules/urlscan.py +++ b/bbot/modules/urlscan.py @@ -22,22 +22,34 @@ async def setup(self): async def handle_event(self, event): query = self.make_query(event) for domain, url in await self.query(query): - source_event = event + parent_event = event if domain and domain != query: - domain_event = self.make_event(domain, "DNS_NAME", source=event) + domain_event = self.make_event(domain, "DNS_NAME", parent=event) if domain_event: if str(domain_event.host).endswith(query) and not str(domain_event.host) == str(event.host): - await self.emit_event(domain_event, abort_if=self.abort_if) - source_event = domain_event + await self.emit_event( + domain_event, + abort_if=self.abort_if, + context=f'{{module}} searched urlscan.io API for "{query}" and found {{event.type}}: {{event.data}}', + ) + parent_event = domain_event if url: - url_event = self.make_event(url, "URL_UNVERIFIED", source=source_event) + url_event = self.make_event(url, "URL_UNVERIFIED", parent=parent_event) if url_event: if str(url_event.host).endswith(query): if self.urls: - await self.emit_event(url_event, abort_if=self.abort_if) + await self.emit_event( + url_event, + abort_if=self.abort_if, + context=f'{{module}} searched urlscan.io API for "{query}" and found {{event.type}}: {{event.data}}', + ) else: await self.emit_event( - str(url_event.host), "DNS_NAME", source=event, abort_if=self.abort_if + str(url_event.host), + "DNS_NAME", + parent=event, + abort_if=self.abort_if, + context=f'{{module}} searched urlscan.io API for "{query}" and found {{event.type}}: {{event.data}}', ) else: self.debug(f"{url_event.host} does not match {query}") diff --git a/bbot/modules/viewdns.py b/bbot/modules/viewdns.py index 2c1ea73bf..96fd6fe94 100644 --- a/bbot/modules/viewdns.py +++ b/bbot/modules/viewdns.py @@ -28,7 +28,13 @@ async def setup(self): async def handle_event(self, event): _, query = self.helpers.split_domain(event.data) for domain, _ in await self.query(query): - await self.emit_event(domain, "DNS_NAME", source=event, tags=["affiliate"]) + await self.emit_event( + domain, + "DNS_NAME", + parent=event, + tags=["affiliate"], + context=f'{{module}} searched viewdns.info for "{query}" and found {{event.type}}: {{event.data}}', + ) async def query(self, query): results = set() diff --git a/bbot/modules/wafw00f.py b/bbot/modules/wafw00f.py index c8f9c4b33..a9c7aca76 100644 --- a/bbot/modules/wafw00f.py +++ b/bbot/modules/wafw00f.py @@ -36,24 +36,34 @@ async def filter_event(self, event): return False, f"Invalid HTTP status code: {http_status}" return True, "" + def _incoming_dedup_hash(self, event): + return hash(f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/") + async def handle_event(self, event): - url = f"{event.parsed.scheme}://{event.parsed.netloc}/" - WW = await self.scan.run_in_executor(wafw00f_main.WAFW00F, url, followredirect=False) - waf_detections = await self.scan.run_in_executor(WW.identwaf) + url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/" + WW = await self.helpers.run_in_executor(wafw00f_main.WAFW00F, url, followredirect=False) + waf_detections = await self.helpers.run_in_executor(WW.identwaf) if waf_detections: for waf in waf_detections: - await self.emit_event({"host": str(event.host), "url": url, "waf": waf}, "WAF", source=event) + await self.emit_event( + {"host": str(event.host), "url": url, "waf": waf}, + "WAF", + parent=event, + context=f"{{module}} scanned {url} and identified {{event.type}}: {waf}", + ) else: if self.config.get("generic_detect") == True: - generic = await self.scan.run_in_executor(WW.genericdetect) + generic = await self.helpers.run_in_executor(WW.genericdetect) if generic: + waf = "generic detection" await self.emit_event( { "host": str(event.host), "url": url, - "waf": "generic detection", + "waf": waf, "info": WW.knowledge["generic"]["reason"], }, "WAF", - source=event, + parent=event, + context=f"{{module}} scanned {url} and identified {{event.type}}: {waf}", ) diff --git a/bbot/modules/wappalyzer.py b/bbot/modules/wappalyzer.py index ee65e22fe..7d28df1ce 100644 --- a/bbot/modules/wappalyzer.py +++ b/bbot/modules/wappalyzer.py @@ -13,7 +13,7 @@ class wappalyzer(BaseModule): watched_events = ["HTTP_RESPONSE"] produced_events = ["TECHNOLOGY"] - flags = ["active", "safe", "web-basic", "web-thorough"] + flags = ["active", "safe", "web-basic"] meta = { "description": "Extract technologies from web responses", "created_date": "2022-04-15", @@ -22,18 +22,39 @@ class wappalyzer(BaseModule): deps_pip = ["python-Wappalyzer~=0.3.1", "aiohttp~=3.9.0b0"] # accept all events regardless of scope distance scope_distance_modifier = None - _max_event_handlers = 5 + _module_threads = 5 + + @staticmethod + def process_headers(headers): + unique_headers = {} + count = {} + for k, v in headers.items(): + values = v if isinstance(v, list) else [v] + for item in values: + unique_key = k if k not in count else f"{k}_{count[k]}" + while unique_key in unique_headers: + count[k] = count.get(k, 0) + 1 + unique_key = f"{k}_{count[k]}" + unique_headers[unique_key] = item + count[k] = count.get(k, 0) + 1 + return unique_headers async def setup(self): - self.wappalyzer = await self.scan.run_in_executor(Wappalyzer.latest) + self.wappalyzer = await self.helpers.run_in_executor(Wappalyzer.latest) return True async def handle_event(self, event): - for res in await self.scan.run_in_executor(self.wappalyze, event.data): + for res in await self.helpers.run_in_executor(self.wappalyze, event.data): + res = res.lower() await self.emit_event( - {"technology": res.lower(), "url": event.data["url"], "host": str(event.host)}, "TECHNOLOGY", event + {"technology": res, "url": event.data["url"], "host": str(event.host)}, + "TECHNOLOGY", + event, + context=f"{{module}} analyzed HTTP_RESPONSE and identified {{event.type}}: {res}", ) def wappalyze(self, data): - w = WebPage(url=data["url"], html=data.get("body", ""), headers=data.get("header-dict", {})) + # Convert dictionary of lists to a dictionary of strings + header_dict = self.process_headers(data.get("header-dict", {})) + w = WebPage(url=data["url"], html=data.get("body", ""), headers=header_dict) return self.wappalyzer.analyze(w) diff --git a/bbot/modules/wayback.py b/bbot/modules/wayback.py index 0cf34ce2b..647ea342f 100644 --- a/bbot/modules/wayback.py +++ b/bbot/modules/wayback.py @@ -29,7 +29,13 @@ async def setup(self): async def handle_event(self, event): query = self.make_query(event) for result, event_type in await self.query(query): - await self.emit_event(result, event_type, event, abort_if=self.abort_if) + await self.emit_event( + result, + event_type, + event, + abort_if=self.abort_if, + context=f'{{module}} queried archive.org for "{query}" and found {{event.type}}: {{event.data}}', + ) async def query(self, query): results = set() @@ -58,7 +64,9 @@ async def query(self, query): dns_names = set() collapsed_urls = 0 start_time = datetime.now() - parsed_urls = await self.scan.run_in_executor_mp( + # we consolidate URLs to cut down on garbage data + # this is CPU-intensive, so we do it in its own core. + parsed_urls = await self.helpers.run_in_executor_mp( self.helpers.validators.collapse_urls, urls, threshold=self.garbage_threshold, diff --git a/bbot/modules/wpscan.py b/bbot/modules/wpscan.py index a5499842c..382bd2606 100644 --- a/bbot/modules/wpscan.py +++ b/bbot/modules/wpscan.py @@ -91,7 +91,7 @@ async def handle_event(self, event): await self.handle_technology(event) async def handle_http_response(self, source_event): - url = source_event.parsed._replace(path="/").geturl() + url = source_event.parsed_url._replace(path="/").geturl() command = self.construct_command(url) output = await self.run_process(command) for new_event in self.parse_wpscan_output(output.stdout, url, source_event): @@ -164,7 +164,7 @@ def parse_wp_misc(self, interesting_json, base_url, source_event): source_event, ) else: - url_event = self.make_event(url, "URL_UNVERIFIED", source=source_event, tags=["httpx-safe"]) + url_event = self.make_event(url, "URL_UNVERIFIED", parent=source_event, tags=["httpx-safe"]) if url_event: url_event.scope_distance = source_event.scope_distance yield url_event @@ -226,7 +226,7 @@ def parse_wp_plugins(self, plugins_json, base_url, source_event): for name, plugin in plugins_json.items(): url = plugin.get("location", base_url) if url != base_url: - url_event = self.make_event(url, "URL_UNVERIFIED", source=source_event, tags=["httpx-safe"]) + url_event = self.make_event(url, "URL_UNVERIFIED", parent=source_event, tags=["httpx-safe"]) if url_event: url_event.scope_distance = source_event.scope_distance yield url_event diff --git a/bbot/modules/zoomeye.py b/bbot/modules/zoomeye.py index 98397729a..fc4cfbfee 100644 --- a/bbot/modules/zoomeye.py +++ b/bbot/modules/zoomeye.py @@ -41,7 +41,13 @@ async def handle_event(self, event): tags = [] if not hostname.endswith(f".{query}"): tags = ["affiliate"] - await self.emit_event(hostname, "DNS_NAME", event, tags=tags) + await self.emit_event( + hostname, + "DNS_NAME", + event, + tags=tags, + context=f'{{module}} searched ZoomEye API for "{query}" and found {{event.type}}: {{event.data}}', + ) async def query(self, query): results = set() diff --git a/bbot/presets/cloud-enum.yml b/bbot/presets/cloud-enum.yml new file mode 100644 index 000000000..6f19c5c35 --- /dev/null +++ b/bbot/presets/cloud-enum.yml @@ -0,0 +1,7 @@ +description: Enumerate cloud resources such as storage buckets, etc. + +include: + - subdomain-enum + +flags: + - cloud-enum diff --git a/bbot/presets/code-enum.yml b/bbot/presets/code-enum.yml new file mode 100644 index 000000000..8e91e5674 --- /dev/null +++ b/bbot/presets/code-enum.yml @@ -0,0 +1,4 @@ +description: Enumerate Git repositories, Docker images, etc. + +flags: + - code-enum diff --git a/bbot/presets/email-enum.yml b/bbot/presets/email-enum.yml new file mode 100644 index 000000000..a5ffd6e3c --- /dev/null +++ b/bbot/presets/email-enum.yml @@ -0,0 +1,7 @@ +description: Enumerate email addresses from APIs, web crawling, etc. + +flags: + - email-enum + +output_modules: + - emails diff --git a/bbot/presets/kitchen-sink.yml b/bbot/presets/kitchen-sink.yml new file mode 100644 index 000000000..624d597d2 --- /dev/null +++ b/bbot/presets/kitchen-sink.yml @@ -0,0 +1,19 @@ +description: Everything everywhere all at once + +include: + - subdomain-enum + - cloud-enum + - code-enum + - email-enum + - spider + - web-basic + - paramminer + - dirbust-light + - web-screenshots + +config: + modules: + baddns: + enable_references: True + + diff --git a/bbot/presets/spider.yml b/bbot/presets/spider.yml new file mode 100644 index 000000000..0ffb495c4 --- /dev/null +++ b/bbot/presets/spider.yml @@ -0,0 +1,13 @@ +description: Recursive web spider + +modules: + - httpx + +config: + web: + # how many links to follow in a row + spider_distance: 2 + # don't follow links whose directory depth is higher than 4 + spider_depth: 4 + # maximum number of links to follow per page + spider_links_per_page: 25 diff --git a/bbot/presets/subdomain-enum.yml b/bbot/presets/subdomain-enum.yml new file mode 100644 index 000000000..fc4ae4aa2 --- /dev/null +++ b/bbot/presets/subdomain-enum.yml @@ -0,0 +1,22 @@ +description: Enumerate subdomains via APIs, brute-force + +flags: + # enable every module with the subdomain-enum flag + - subdomain-enum + +output_modules: + # output unique subdomains to TXT file + - subdomains + +config: + dns: + threads: 25 + brute_threads: 1000 + # put your API keys here + modules: + github: + api_key: "" + chaos: + api_key: "" + securitytrails: + api_key: "" diff --git a/bbot/presets/web-basic.yml b/bbot/presets/web-basic.yml new file mode 100644 index 000000000..166d973e9 --- /dev/null +++ b/bbot/presets/web-basic.yml @@ -0,0 +1,7 @@ +description: Quick web scan + +include: + - iis-shortnames + +flags: + - web-basic diff --git a/bbot/presets/web-screenshots.yml b/bbot/presets/web-screenshots.yml new file mode 100644 index 000000000..4641e7be3 --- /dev/null +++ b/bbot/presets/web-screenshots.yml @@ -0,0 +1,14 @@ +description: Take screenshots of webpages + +flags: + - web-screenshots + +config: + modules: + gowitness: + resolution_x: 1440 + resolution_y: 900 + # folder to output web screenshots (default is inside ~/.bbot/scans/scan_name) + output_path: "" + # whether to take screenshots of social media pages + social: True diff --git a/bbot/presets/web-thorough.yml b/bbot/presets/web-thorough.yml new file mode 100644 index 000000000..0294614f7 --- /dev/null +++ b/bbot/presets/web-thorough.yml @@ -0,0 +1,8 @@ +description: Aggressive web scan + +include: + # include the web-basic preset + - web-basic + +flags: + - web-thorough diff --git a/bbot/presets/web/dirbust-heavy.yml b/bbot/presets/web/dirbust-heavy.yml new file mode 100644 index 000000000..effba2554 --- /dev/null +++ b/bbot/presets/web/dirbust-heavy.yml @@ -0,0 +1,39 @@ +description: Recursive web directory brute-force (aggressive) + +include: + - spider + +flags: + - iis-shortnames + +modules: + - ffuf + - wayback + +config: + modules: + iis_shortnames: + # we exploit the shortnames vulnerability to produce URL_HINTs which are consumed by ffuf_shortnames + detect_only: False + ffuf: + depth: 3 + lines: 5000 + extensions: + - php + - asp + - aspx + - ashx + - asmx + - jsp + - jspx + - cfm + - zip + - conf + - config + - xml + - json + - yml + - yaml + # emit URLs from wayback + wayback: + urls: True diff --git a/bbot/presets/web/dirbust-light.yml b/bbot/presets/web/dirbust-light.yml new file mode 100644 index 000000000..d088ee24e --- /dev/null +++ b/bbot/presets/web/dirbust-light.yml @@ -0,0 +1,13 @@ +description: Basic web directory brute-force (surface-level directories only) + +include: + - iis-shortnames + +modules: + - ffuf + +config: + modules: + ffuf: + # wordlist size = 1000 + lines: 1000 diff --git a/bbot/presets/web/dotnet-audit.yml b/bbot/presets/web/dotnet-audit.yml new file mode 100644 index 000000000..bbc5e201e --- /dev/null +++ b/bbot/presets/web/dotnet-audit.yml @@ -0,0 +1,22 @@ +description: Comprehensive scan for all IIS/.NET specific modules and module settings + + +include: + - iis-shortnames + +modules: + - httpx + - badsecrets + - ffuf_shortnames + - ffuf + - telerik + - ajaxpro + - dotnetnuke + +config: + modules: + ffuf: + extensions: asp,aspx,ashx,asmx,ascx + telerik: + exploit_RAU_crypto: True + diff --git a/bbot/presets/web/iis-shortnames.yml b/bbot/presets/web/iis-shortnames.yml new file mode 100644 index 000000000..bae21c040 --- /dev/null +++ b/bbot/presets/web/iis-shortnames.yml @@ -0,0 +1,10 @@ +description: Recursively enumerate IIS shortnames + +flags: + - iis-shortnames + +config: + modules: + iis_shortnames: + # exploit the vulnerability + detect_only: false diff --git a/bbot/presets/web/paramminer.yml b/bbot/presets/web/paramminer.yml new file mode 100644 index 000000000..7d36e3a84 --- /dev/null +++ b/bbot/presets/web/paramminer.yml @@ -0,0 +1,12 @@ +description: Discover new web parameters via brute-force + +flags: + - web-paramminer + +modules: + - httpx + +config: + web: + spider_distance: 1 + spider_depth: 4 diff --git a/bbot/scanner/__init__.py b/bbot/scanner/__init__.py index cc993af8a..1622f4c20 100644 --- a/bbot/scanner/__init__.py +++ b/bbot/scanner/__init__.py @@ -1 +1,2 @@ +from .preset import Preset from .scanner import Scanner diff --git a/bbot/scanner/manager.py b/bbot/scanner/manager.py index 96dc6be28..1f4f293ec 100644 --- a/bbot/scanner/manager.py +++ b/bbot/scanner/manager.py @@ -1,57 +1,40 @@ import asyncio import logging -import traceback from contextlib import suppress -from ..core.errors import ValidationError -from ..core.helpers.async_helpers import TaskCounter, ShuffleQueue +from bbot.modules.base import InterceptModule log = logging.getLogger("bbot.scanner.manager") -class ScanManager: +class ScanIngress(InterceptModule): """ - Manages the modules, event queues, and overall event flow during a scan. - - Simultaneously serves as a policeman, judge, jury, and executioner for events. - It is responsible for managing the incoming event queue and distributing events to modules. - - Attributes: - scan (Scan): Reference to the Scan object that instantiated the ScanManager. - incoming_event_queue (ShuffleQueue): Queue storing incoming events for processing. - events_distributed (set): Set tracking globally unique events. - events_accepted (set): Set tracking events accepted by individual modules. - dns_resolution (bool): Flag to enable or disable DNS resolution. - _task_counter (TaskCounter): Counter for ongoing tasks. - _new_activity (bool): Flag indicating new activity. - _modules_by_priority (dict): Modules sorted by their priorities. - _incoming_queues (list): List of incoming event queues from each module. - _module_priority_weights (list): Weight values for each module based on priority. + This is always the first intercept module in the chain, responsible for basic scope checks + + It has its own incoming queue, but will also pull events from modules' outgoing queues """ - def __init__(self, scan): - """ - Initializes the ScanManager object, setting up essential attributes for scan management. + watched_events = ["*"] + # accept all events regardless of scope distance + scope_distance_modifier = None + _name = "_scan_ingress" - Args: - scan (Scan): Reference to the Scan object that instantiated the ScanManager. - """ + # small queue size so we don't drain modules' outgoing queues + _qsize = 10 - self.scan = scan + @property + def priority(self): + # we are the highest priority + return -99 - self.incoming_event_queue = ShuffleQueue() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._module_priority_weights = None + self._non_intercept_modules = None # track incoming duplicates module-by-module (for `suppress_dupes` attribute of modules) self.incoming_dup_tracker = set() - # track outgoing duplicates (for `accept_dupes` attribute of modules) - self.outgoing_dup_tracker = set() - self.dns_resolution = self.scan.config.get("dns_resolution", False) - self._task_counter = TaskCounter() - self._new_activity = True - self._modules_by_priority = None - self._incoming_queues = None - self._module_priority_weights = None - async def init_events(self): + async def init_events(self, events=None): """ Initializes events by seeding the scanner with target events and distributing them for further processing. @@ -59,554 +42,201 @@ async def init_events(self): - This method populates the event queue with initial target events. - It also marks the Scan object as finished with initialization by setting `_finished_init` to True. """ - - context = f"manager.init_events()" - async with self.scan._acatch(context), self._task_counter.count(context): - await self.distribute_event(self.scan.root_event) - sorted_events = sorted(self.scan.target.events, key=lambda e: len(e.data)) - for event in sorted_events: - self.scan.verbose(f"Target: {event}") - self.queue_event(event) + if events is None: + events = self.scan.target.events + async with self.scan._acatch(self.init_events), self._task_counter.count(self.init_events): + sorted_events = sorted(events, key=lambda e: len(e.data)) + for event in [self.scan.root_event] + sorted_events: + event._dummy = False + event.web_spider_distance = 0 + event.scan = self.scan + if event.parent is None: + event.parent = self.scan.root_event + if event.module is None: + event.module = self.scan._make_dummy_module(name="TARGET", _type="TARGET") + event.add_tag("target") + if event != self.scan.root_event: + event.discovery_context = f"Scan {self.scan.name} seeded with " + "{event.type}: {event.data}" + self.verbose(f"Target: {event}") + await self.queue_event(event, {}) await asyncio.sleep(0.1) self.scan._finished_init = True - async def emit_event(self, event, *args, **kwargs): - """ - TODO: Register + kill duplicate events immediately? - bbot.scanner: scan._event_thread_pool: running for 0 seconds: ScanManager._emit_event(DNS_NAME("sipfed.online.lync.com")) - bbot.scanner: scan._event_thread_pool: running for 0 seconds: ScanManager._emit_event(DNS_NAME("sipfed.online.lync.com")) - bbot.scanner: scan._event_thread_pool: running for 0 seconds: ScanManager._emit_event(DNS_NAME("sipfed.online.lync.com")) - """ - callbacks = ["abort_if", "on_success_callback"] - callbacks_requested = any([kwargs.get(k, None) is not None for k in callbacks]) - # "quick" queues the event immediately - # This is used by speculate - quick_kwarg = kwargs.pop("quick", False) - quick_event = getattr(event, "quick_emit", False) - quick = (quick_kwarg or quick_event) and not callbacks_requested - - # skip event if it fails precheck - if event.type != "DNS_NAME": - acceptable = self._event_precheck(event) - if not acceptable: - event._resolved.set() - return - - log.debug(f'Module "{event.module}" raised {event}') - - if quick: - log.debug(f"Quick-emitting {event}") - event._resolved.set() - for kwarg in callbacks: - kwargs.pop(kwarg, None) - async with self.scan._acatch(context=self.distribute_event): - await self.distribute_event(event) - else: - async with self.scan._acatch(context=self._emit_event, finally_callback=event._resolved.set): - await self._emit_event( - event, - *args, - **kwargs, - ) - - def _event_precheck(self, event): - """ - Check an event to see if we can skip it to save on performance - """ + async def handle_event(self, event, kwargs): + # don't accept dummy events if event._dummy: - log.warning(f"Cannot emit dummy event: {event}") - return False - if event == event.get_source(): - log.debug(f"Skipping event with self as source: {event}") - return False - if event._graph_important: - return True - if self.is_incoming_duplicate(event, add=True): - log.debug(f"Skipping event because it was already emitted by its module: {event}") - return False - return True - - async def _emit_event(self, event, **kwargs): - """ - Handles the emission, tagging, and distribution of a events during a scan. + return False, "cannot emit dummy event" - A lot of really important stuff happens here. Actually this is probably the most - important method in all of BBOT. It is basically the central intersection that - every event passes through. + # don't accept events with self as parent + if not event.type == "SCAN": + if event == event.get_parent(): + return False, "event's parent is itself" + if not event.discovery_context: + self.warning(f"Event {event} has no discovery context") - It exists in a delicate balance. Close to half of my debugging time has been spent - in this function. I have slain many dragons here and there may still be more yet to slay. - - Tread carefully, friend. -TheTechromancer - - Notes: - - Central function for decision-making in BBOT. - - Conducts DNS resolution, tagging, and scope calculations. - - Checks against whitelists and blacklists. - - Calls custom callbacks. - - Handles DNS wildcard events. - - Decides on event acceptance and distribution. - - Parameters: - event (Event): The event object to be emitted. - **kwargs: Arbitrary keyword arguments (e.g., `on_success_callback`, `abort_if`). - - Side Effects: - - Event tagging. - - Populating DNS data. - - Emitting new events. - - Queueing events for further processing. - - Adjusting event scopes. - - Running callbacks. - - Updating scan statistics. - """ - log.debug(f"Emitting {event}") - try: - on_success_callback = kwargs.pop("on_success_callback", None) - abort_if = kwargs.pop("abort_if", None) - - # skip DNS resolution if it's disabled in the config and the event is a target and we don't have a blacklist - skip_dns_resolution = (not self.dns_resolution) and "target" in event.tags and not self.scan.blacklist - if skip_dns_resolution: - event._resolved.set() - dns_children = {} - dns_tags = {"resolved"} - event_whitelisted_dns = True - event_blacklisted_dns = False - resolved_hosts = [] + # don't accept duplicates + if self.is_incoming_duplicate(event, add=True): + if not event._graph_important: + return False, "event was already emitted by its module" else: - # DNS resolution - ( - dns_tags, - event_whitelisted_dns, - event_blacklisted_dns, - dns_children, - ) = await self.scan.helpers.dns.resolve_event(event, minimal=not self.dns_resolution) - resolved_hosts = set() - for rdtype, ips in dns_children.items(): - if rdtype in ("A", "AAAA", "CNAME"): - for ip in ips: - resolved_hosts.add(ip) - - # kill runaway DNS chains - dns_resolve_distance = getattr(event, "dns_resolve_distance", 0) - if dns_resolve_distance >= self.scan.helpers.dns.max_dns_resolve_distance: - log.debug( - f"Skipping DNS children for {event} because their DNS resolve distances would be greater than the configured value for this scan ({self.scan.helpers.dns.max_dns_resolve_distance})" + self.debug( + f"Event {event} was already emitted by its module, but it's graph-important so it gets a pass" ) - dns_children = {} - if event.type in ("DNS_NAME", "IP_ADDRESS"): - event._dns_children = dns_children - for tag in dns_tags: - event.add_tag(tag) + # update event's scope distance based on its parent + event.scope_distance = event.parent.scope_distance + 1 - event._resolved_hosts = resolved_hosts + # special handling of URL extensions + url_extension = getattr(event, "url_extension", None) + if url_extension is not None: + if url_extension in self.scan.url_extension_httpx_only: + event.add_tag("httpx-only") + event._omit = True - event_whitelisted = event_whitelisted_dns | self.scan.whitelisted(event) - event_blacklisted = event_blacklisted_dns | self.scan.blacklisted(event) - if event_blacklisted: + # blacklist by extension + if url_extension in self.scan.url_extension_blacklist: + self.debug( + f"Blacklisting {event} because its extension (.{url_extension}) is blacklisted in the config" + ) event.add_tag("blacklisted") - reason = "event host" - if event_blacklisted_dns: - reason = "DNS associations" - log.debug(f"Omitting due to blacklisted {reason}: {event}") - return - - # other blacklist rejections - URL extensions, etc. - if "blacklisted" in event.tags: - log.debug(f"Omitting blacklisted event: {event}") - return - - # DNS_NAME --> DNS_NAME_UNRESOLVED - if event.type == "DNS_NAME" and "unresolved" in event.tags and not "target" in event.tags: - event.type = "DNS_NAME_UNRESOLVED" - - # Cloud tagging - await self.scan.helpers.cloud.tag_event(event) - - # Scope shepherding - # here is where we make sure in-scope events are set to their proper scope distance - if event.host and event_whitelisted: - log.debug(f"Making {event} in-scope") - event.scope_distance = 0 - - # check for wildcards - if event.scope_distance <= self.scan.scope_search_distance: - if not "unresolved" in event.tags: - if not self.scan.helpers.is_ip_type(event.host): - await self.scan.helpers.dns.handle_wildcard_event(event, dns_children) - - # For DNS_NAMEs, we've waited to do this until now, in case event.data changed during handle_wildcard_event() - if event.type == "DNS_NAME": - acceptable = self._event_precheck(event) - if not acceptable: - return - - # now that the event is properly tagged, we can finally make decisions about it - abort_result = False - if callable(abort_if): - async with self.scan._acatch(context=abort_if): - abort_result = await self.scan.helpers.execute_sync_or_async(abort_if, event) - msg = f"{event.module}: not raising event {event} due to custom criteria in abort_if()" - with suppress(ValueError, TypeError): - abort_result, reason = abort_result - msg += f": {reason}" - if abort_result: - log.verbose(msg) - return - - # run success callback before distributing event (so it can add tags, etc.) - if callable(on_success_callback): - async with self.scan._acatch(context=on_success_callback): - await self.scan.helpers.execute_sync_or_async(on_success_callback, event) - - await self.distribute_event(event) - - # speculate DNS_NAMES and IP_ADDRESSes from other event types - source_event = event - if ( - event.host - and event.type not in ("DNS_NAME", "DNS_NAME_UNRESOLVED", "IP_ADDRESS", "IP_RANGE") - and not (event.type in ("OPEN_TCP_PORT", "URL_UNVERIFIED") and str(event.module) == "speculate") - ): - source_module = self.scan.helpers._make_dummy_module("host", _type="internal") - source_module._priority = 4 - source_event = self.scan.make_event(event.host, "DNS_NAME", module=source_module, source=event) - # only emit the event if it's not already in the parent chain - if source_event is not None and source_event not in source_event.get_sources(): - source_event.scope_distance = event.scope_distance - if "target" in event.tags: - source_event.add_tag("target") - self.queue_event(source_event) - - ### Emit DNS children ### - if self.dns_resolution: - emit_children = True - in_dns_scope = -1 < event.scope_distance < self.scan.scope_dns_search_distance - # only emit DNS children once for each unique host - host_hash = hash(str(event.host)) - if host_hash in self.outgoing_dup_tracker: - emit_children = False - self.outgoing_dup_tracker.add(host_hash) - - if emit_children: - dns_child_events = [] - if dns_children: - for rdtype, records in dns_children.items(): - module = self.scan.helpers.dns._get_dummy_module(rdtype) - module._priority = 4 - for record in records: - try: - child_event = self.scan.make_event( - record, "DNS_NAME", module=module, source=source_event - ) - # if it's a hostname and it's only one hop away, mark it as affiliate - if child_event.type == "DNS_NAME" and child_event.scope_distance == 1: - child_event.add_tag("affiliate") - host_hash = hash(str(child_event.host)) - if in_dns_scope or self.scan.in_scope(child_event): - dns_child_events.append(child_event) - except ValidationError as e: - log.warning( - f'Event validation failed for DNS child of {source_event}: "{record}" ({rdtype}): {e}' - ) - for child_event in dns_child_events: - log.debug(f"Queueing DNS child for {event}: {child_event}") - self.queue_event(child_event) - - except ValidationError as e: - log.warning(f"Event validation failed with kwargs={kwargs}: {e}") - log.trace(traceback.format_exc()) - - finally: - event._resolved.set() - log.debug(f"{event.module}.emit_event() finished for {event}") - def is_incoming_duplicate(self, event, add=False): - """ - Calculate whether an event is a duplicate in the context of the module that emitted it - This will return True if the event's parent module has raised the event before. - """ - try: - event_hash = event.module._outgoing_dedup_hash(event) - except AttributeError: - event_hash = hash((event, str(getattr(event, "module", "")))) - is_dup = event_hash in self.incoming_dup_tracker - if add: - self.incoming_dup_tracker.add(event_hash) - suppress_dupes = getattr(event.module, "suppress_dupes", True) - if suppress_dupes and is_dup: - return True - return False + # main scan blacklist + event_blacklisted = self.scan.blacklisted(event) - def is_outgoing_duplicate(self, event, add=False): - """ - Calculate whether an event is a duplicate in the context of the whole scan, - This will return True if the same event (irregardless of its source module) has been emitted before. + # reject all blacklisted events + if event_blacklisted or "blacklisted" in event.tags: + return False, "event is blacklisted" - TODO: Allow modules to use this for custom deduplication such as on a per-host or per-domain basis. - """ - event_hash = hash(event) - is_dup = event_hash in self.outgoing_dup_tracker - if add: - self.outgoing_dup_tracker.add(event_hash) - return is_dup + # Scope shepherding + # here is where we make sure in-scope events are set to their proper scope distance + event_whitelisted = self.scan.whitelisted(event) + if event.host and event_whitelisted: + log.debug(f"Making {event} in-scope because it matches the scan target") + event.scope_distance = 0 - async def distribute_event(self, event): - """ - Queue event with modules - """ - async with self.scan._acatch(context=self.distribute_event): - # make event internal if it's above our configured report distance - event_in_report_distance = event.scope_distance <= self.scan.scope_report_distance - event_will_be_output = event.always_emit or event_in_report_distance - if not event_will_be_output: - log.debug( - f"Making {event} internal because its scope_distance ({event.scope_distance}) > scope_report_distance ({self.scan.scope_report_distance})" - ) - event.internal = True - - # if we discovered something interesting from an internal event, - # make sure we preserve its chain of parents - source = event.source - if source.internal and ((not event.internal) or event._graph_important): - source_in_report_distance = source.scope_distance <= self.scan.scope_report_distance - if source_in_report_distance: - source.internal = False - if not source._graph_important: - source._graph_important = True - log.debug(f"Re-queuing internal event {source} with parent {event}") - self.queue_event(source) - - is_outgoing_duplicate = self.is_outgoing_duplicate(event) - if is_outgoing_duplicate: - self.scan.verbose(f"{event.module}: Duplicate event: {event}") - # absorb event into the word cloud if it's in scope - if not is_outgoing_duplicate and -1 < event.scope_distance < 1: - self.scan.word_cloud.absorb_event(event) - for mod in self.scan.modules.values(): - acceptable_dup = (not is_outgoing_duplicate) or mod.accept_dupes - # graph_important = mod._type == "output" and event._graph_important == True - graph_important = mod._is_graph_important(event) - if acceptable_dup or graph_important: - await mod.queue_event(event) - - async def _worker_loop(self): - try: - while not self.scan.stopped: - try: - async with self._task_counter.count("get_event_from_modules()"): - event, kwargs = self.get_event_from_modules() - except asyncio.queues.QueueEmpty: - await asyncio.sleep(0.1) - continue - async with self._task_counter.count(f"emit_event({event})"): - emit_event_task = asyncio.create_task( - self.emit_event(event, **kwargs), name=f"emit_event({event})" - ) - await emit_event_task - - except Exception: - log.critical(traceback.format_exc()) - - def kill_module(self, module_name, message=None): - from signal import SIGINT - - module = self.scan.modules[module_name] - module.set_error_state(message=message, clear_outgoing_queue=True) - for proc in module._proc_tracker: - with suppress(Exception): - proc.send_signal(SIGINT) - self.scan.helpers.cancel_tasks_sync(module._tasks) + # nerf event's priority if it's not in scope + event.module_priority += event.scope_distance - @property - def modules_by_priority(self): - if not self._modules_by_priority: - self._modules_by_priority = sorted(list(self.scan.modules.values()), key=lambda m: m.priority) - return self._modules_by_priority + async def forward_event(self, event, kwargs): + # if a module qualifies for "quick-emit", we skip all the intermediate modules like dns and cloud + # and forward it straight to the egress module + if event.quick_emit: + await self.scan.egress_module.queue_event(event, kwargs) + else: + await super().forward_event(event, kwargs) @property - def incoming_queues(self): - if not self._incoming_queues: - queues_by_priority = [m.outgoing_event_queue for m in self.modules_by_priority] - self._incoming_queues = [self.incoming_event_queue] + queues_by_priority - return self._incoming_queues + def non_intercept_modules(self): + if self._non_intercept_modules is None: + self._non_intercept_modules = [m for m in self.scan.modules.values() if not m._intercept] + return self._non_intercept_modules @property - def incoming_qsize(self): - incoming_events = 0 - for q in self.incoming_queues: - incoming_events += q.qsize() - return incoming_events + def incoming_queues(self): + queues = [self.incoming_event_queue] + [m.outgoing_event_queue for m in self.non_intercept_modules] + return [q for q in queues if q is not False] @property def module_priority_weights(self): if not self._module_priority_weights: # we subtract from six because lower priorities == higher weights - priorities = [5] + [6 - m.priority for m in self.modules_by_priority] + priorities = [5] + [6 - m.priority for m in self.non_intercept_modules] self._module_priority_weights = priorities return self._module_priority_weights - def get_event_from_modules(self): - for q in self.scan.helpers.weighted_shuffle(self.incoming_queues, self.module_priority_weights): + async def get_incoming_event(self): + for q in self.helpers.weighted_shuffle(self.incoming_queues, self.module_priority_weights): try: return q.get_nowait() except (asyncio.queues.QueueEmpty, AttributeError): continue raise asyncio.queues.QueueEmpty() - @property - def num_queued_events(self): - total = 0 - for q in self.incoming_queues: - total += len(q._queue) - return total - - def queue_event(self, event, **kwargs): - if event: - # nerf event's priority if it's likely not to be in scope - if event.scope_distance > 0: - event_in_scope = self.scan.whitelisted(event) and not self.scan.blacklisted(event) - if not event_in_scope: - event.module_priority += event.scope_distance - # Wait for parent event to resolve (in case its scope distance changes) - # await resolved = event.source._resolved.wait() - # update event's scope distance based on its parent - event.scope_distance = event.source.scope_distance + 1 - self.incoming_event_queue.put_nowait((event, kwargs)) - - @property - def running(self): - active_tasks = self._task_counter.value - incoming_events = self.incoming_qsize - return active_tasks > 0 or incoming_events > 0 - - @property - def modules_finished(self): - finished_modules = [m.finished for m in self.scan.modules.values()] - return all(finished_modules) + def is_incoming_duplicate(self, event, add=False): + """ + Calculate whether an event is a duplicate in the context of the module that emitted it + This will return True if the event's parent module has raised the event before. + """ + try: + event_hash = event.module._outgoing_dedup_hash(event) + except AttributeError: + module_name = str(getattr(event, "module", "")) + event_hash = hash((event, module_name)) + is_dup = event_hash in self.incoming_dup_tracker + if add: + self.incoming_dup_tracker.add(event_hash) + suppress_dupes = getattr(event.module, "suppress_dupes", True) + if suppress_dupes and is_dup: + return True + return False - @property - def active(self): - return self.running or not self.modules_finished - def modules_status(self, _log=False): - finished = True - status = {"modules": {}} +class ScanEgress(InterceptModule): + """ + This is always the last intercept module in the chain, responsible for executing and acting on the + `abort_if` and `on_success_callback` functions. + """ - for m in self.scan.modules.values(): - mod_status = m.status - if mod_status["running"]: - finished = False - status["modules"][m.name] = mod_status + watched_events = ["*"] + # accept all events regardless of scope distance + scope_distance_modifier = None + _name = "_scan_egress" - for mod in self.scan.modules.values(): - if mod.errored and mod.incoming_event_queue not in [None, False]: - with suppress(Exception): - mod.set_error_state() - - status["finished"] = finished - - modules_errored = [m for m, s in status["modules"].items() if s["errored"]] - - max_mem_percent = 90 - mem_status = self.scan.helpers.memory_status() - # abort if we don't have the memory - mem_percent = mem_status.percent - if mem_percent > max_mem_percent: - free_memory = mem_status.available - free_memory_human = self.scan.helpers.bytes_to_human(free_memory) - self.scan.warning(f"System memory is at {mem_percent:.1f}% ({free_memory_human} remaining)") - - if _log: - modules_status = [] - for m, s in status["modules"].items(): - running = s["running"] - incoming = s["events"]["incoming"] - outgoing = s["events"]["outgoing"] - tasks = s["tasks"] - total = sum([incoming, outgoing, tasks]) - if running or total > 0: - modules_status.append((m, running, incoming, outgoing, tasks, total)) - modules_status.sort(key=lambda x: x[-1], reverse=True) - - if modules_status: - modules_status_str = ", ".join([f"{m}({i:,}:{t:,}:{o:,})" for m, r, i, o, t, _ in modules_status]) - self.scan.info( - f"{self.scan.name}: Modules running (incoming:processing:outgoing) {modules_status_str}" - ) - else: - self.scan.info(f"{self.scan.name}: No modules running") - event_type_summary = sorted( - self.scan.stats.events_emitted_by_type.items(), key=lambda x: x[-1], reverse=True + @property + def priority(self): + # we are the lowest priority + return 99 + + async def handle_event(self, event, kwargs): + abort_if = kwargs.pop("abort_if", None) + on_success_callback = kwargs.pop("on_success_callback", None) + + # make event internal if it's above our configured report distance + event_in_report_distance = event.scope_distance <= self.scan.scope_report_distance + event_will_be_output = event.always_emit or event_in_report_distance + if not event_will_be_output: + log.debug( + f"Making {event} internal because its scope_distance ({event.scope_distance}) > scope_report_distance ({self.scan.scope_report_distance})" ) - if event_type_summary: - self.scan.info( - f'{self.scan.name}: Events produced so far: {", ".join([f"{k}: {v}" for k,v in event_type_summary])}' - ) - else: - self.scan.info(f"{self.scan.name}: No events produced yet") - - if modules_errored: - self.scan.verbose( - f'{self.scan.name}: Modules errored: {len(modules_errored):,} ({", ".join([m for m in modules_errored])})' - ) + event.internal = True + + # if we discovered something interesting from an internal event, + # make sure we preserve its chain of parents + parent = event.parent + if parent.internal and ((not event.internal) or event._graph_important): + parent_in_report_distance = parent.scope_distance <= self.scan.scope_report_distance + if parent_in_report_distance: + parent.internal = False + if not parent._graph_important: + parent._graph_important = True + log.debug(f"Re-queuing internal event {parent} with parent {event}") + await self.emit_event(parent) + + abort_result = False + if callable(abort_if): + async with self.scan._acatch(context=abort_if): + abort_result = await self.scan.helpers.execute_sync_or_async(abort_if, event) + msg = f"{event.module}: not raising event {event} due to custom criteria in abort_if()" + with suppress(ValueError, TypeError): + abort_result, reason = abort_result + msg += f": {reason}" + if abort_result: + return False, msg + + # run success callback before distributing event (so it can add tags, etc.) + if callable(on_success_callback): + async with self.scan._acatch(context=on_success_callback): + await self.scan.helpers.execute_sync_or_async(on_success_callback, event) + + async def forward_event(self, event, kwargs): + """ + Queue event with modules + """ + # absorb event into the word cloud if it's in scope + if -1 < event.scope_distance < 1: + self.scan.word_cloud.absorb_event(event) - num_queued_events = self.num_queued_events - if num_queued_events: - self.scan.info(f"{self.scan.name}: {num_queued_events:,} events in queue") - else: - self.scan.info(f"{self.scan.name}: No events in queue") - - if self.scan.log_level <= logging.DEBUG: - # status debugging - scan_active_status = [] - scan_active_status.append(f"scan._finished_init: {self.scan._finished_init}") - scan_active_status.append(f"manager.active: {self.active}") - scan_active_status.append(f" manager.running: {self.running}") - scan_active_status.append(f" manager._task_counter.value: {self._task_counter.value}") - scan_active_status.append(f" manager._task_counter.tasks:") - for task in list(self._task_counter.tasks.values()): - scan_active_status.append(f" - {task}:") - scan_active_status.append( - f" manager.incoming_event_queue.qsize: {self.incoming_event_queue.qsize()}" - ) - scan_active_status.append(f" manager.modules_finished: {self.modules_finished}") - for m in sorted(self.scan.modules.values(), key=lambda m: m.name): - running = m.running - scan_active_status.append(f" {m}.finished: {m.finished}") - scan_active_status.append(f" running: {running}") - if running: - scan_active_status.append(f" tasks:") - for task in list(m._task_counter.tasks.values()): - scan_active_status.append(f" - {task}:") - scan_active_status.append(f" incoming_queue_size: {m.num_incoming_events}") - scan_active_status.append(f" outgoing_queue_size: {m.outgoing_event_queue.qsize()}") - for line in scan_active_status: - self.scan.debug(line) - - # log module memory usage - module_memory_usage = [] - for module in self.scan.modules.values(): - memory_usage = module.memory_usage - module_memory_usage.append((module.name, memory_usage)) - module_memory_usage.sort(key=lambda x: x[-1], reverse=True) - self.scan.debug(f"MODULE MEMORY USAGE:") - for module_name, usage in module_memory_usage: - self.scan.debug(f" - {module_name}: {self.scan.helpers.bytes_to_human(usage)}") - - # Uncomment these lines to enable debugging of event queues - - # queued_events = self.incoming_event_queue.events - # if queued_events: - # queued_events_str = ", ".join(str(e) for e in queued_events) - # self.scan.verbose(f"Queued events: {queued_events_str}") - # queued_events_by_module = [(k, v) for k, v in self.incoming_event_queue.modules.items() if v > 0] - # queued_events_by_module.sort(key=lambda x: x[-1], reverse=True) - # queued_events_by_module_str = ", ".join(f"{m}: {t:,}" for m, t in queued_events_by_module) - # self.scan.verbose(f"{self.scan.name}: Queued events by module: {queued_events_by_module_str}") - - status.update({"modules_errored": len(modules_errored)}) - - return status + for mod in self.scan.modules.values(): + # don't distribute events to intercept modules + if not mod._intercept: + await mod.queue_event(event) diff --git a/bbot/scanner/preset/__init__.py b/bbot/scanner/preset/__init__.py new file mode 100644 index 000000000..a6fbc24bb --- /dev/null +++ b/bbot/scanner/preset/__init__.py @@ -0,0 +1 @@ +from .preset import Preset diff --git a/bbot/scanner/preset/args.py b/bbot/scanner/preset/args.py new file mode 100644 index 000000000..9886cae4a --- /dev/null +++ b/bbot/scanner/preset/args.py @@ -0,0 +1,372 @@ +import re +import logging +import argparse +from omegaconf import OmegaConf + +from bbot.errors import * +from bbot.core.helpers.misc import chain_lists, get_closest_match, get_keys_in_dot_syntax + +log = logging.getLogger("bbot.presets.args") + + +class BBOTArgs: + + # module config options to exclude from validation + exclude_from_validation = re.compile(r".*modules\.[a-z0-9_]+\.(?:batch_size|module_threads)$") + + scan_examples = [ + ( + "Subdomains", + "Perform a full subdomain enumeration on evilcorp.com", + "bbot -t evilcorp.com -p subdomain-enum", + ), + ( + "Subdomains (passive only)", + "Perform a passive-only subdomain enumeration on evilcorp.com", + "bbot -t evilcorp.com -p subdomain-enum -rf passive", + ), + ( + "Subdomains + port scan + web screenshots", + "Port-scan every subdomain, screenshot every webpage, output to current directory", + "bbot -t evilcorp.com -p subdomain-enum -m portscan gowitness -n my_scan -o .", + ), + ( + "Subdomains + basic web scan", + "A basic web scan includes wappalyzer, robots.txt, and other non-intrusive web modules", + "bbot -t evilcorp.com -p subdomain-enum web-basic", + ), + ( + "Web spider", + "Crawl www.evilcorp.com up to a max depth of 2, automatically extracting emails, secrets, etc.", + "bbot -t www.evilcorp.com -p spider -c web.spider_distance=2 web.spider_depth=2", + ), + ( + "Everything everywhere all at once", + "Subdomains, emails, cloud buckets, port scan, basic web, web screenshots, nuclei", + "bbot -t evilcorp.com -p kitchen-sink", + ), + ] + + usage_examples = [ + ( + "List modules", + "", + "bbot -l", + ), + ( + "List presets", + "", + "bbot -lp", + ), + ( + "List flags", + "", + "bbot -lf", + ), + ] + + epilog = "EXAMPLES\n" + for example in (scan_examples, usage_examples): + for title, description, command in example: + epilog += f"\n {title}:\n {command}\n" + + def __init__(self, preset): + self.preset = preset + self._config = None + + self.parser = self.create_parser() + self._parsed = None + + @property + def parsed(self): + if self._parsed is None: + self._parsed = self.parser.parse_args() + self.sanitize_args() + return self._parsed + + def preset_from_args(self): + # the order here is important + # first we make the preset + args_preset = self.preset.__class__( + *self.parsed.targets, + whitelist=self.parsed.whitelist, + blacklist=self.parsed.blacklist, + strict_scope=self.parsed.strict_scope, + name="args_preset", + ) + + # then we set verbosity levels (so if the user enables -d they can see debug output) + if self.parsed.silent: + args_preset.silent = True + if self.parsed.verbose: + args_preset.verbose = True + if self.parsed.debug: + args_preset.debug = True + + # then we load requested preset + # this is important so we can load custom module directories, pull in custom flags, module config options, etc. + for preset_arg in self.parsed.preset: + try: + args_preset.include_preset(preset_arg) + except BBOTArgumentError: + raise + except Exception as e: + raise BBOTArgumentError(f'Error parsing preset "{preset_arg}": {e}') + + # modules + flags + args_preset.exclude_modules.update(set(self.parsed.exclude_modules)) + args_preset.exclude_flags.update(set(self.parsed.exclude_flags)) + args_preset.require_flags.update(set(self.parsed.require_flags)) + args_preset.explicit_scan_modules.update(set(self.parsed.modules)) + args_preset.explicit_output_modules.update(set(self.parsed.output_modules)) + args_preset.flags.update(set(self.parsed.flags)) + + # output + if self.parsed.json: + args_preset.core.merge_custom({"modules": {"stdout": {"format": "json"}}}) + if self.parsed.brief: + args_preset.core.merge_custom( + {"modules": {"stdout": {"event_fields": ["type", "scope_description", "data"]}}} + ) + if self.parsed.event_types: + args_preset.core.merge_custom({"modules": {"stdout": {"event_types": self.parsed.event_types}}}) + + # dependencies + if self.parsed.retry_deps: + args_preset.core.custom_config["deps_behavior"] = "retry_failed" + elif self.parsed.force_deps: + args_preset.core.custom_config["deps_behavior"] = "force_install" + elif self.parsed.no_deps: + args_preset.core.custom_config["deps_behavior"] = "disable" + elif self.parsed.ignore_failed_deps: + args_preset.core.custom_config["deps_behavior"] = "ignore_failed" + + # other scan options + args_preset.scan_name = self.parsed.name + args_preset.output_dir = self.parsed.output_dir + args_preset.force_start = self.parsed.force + + if self.parsed.custom_headers: + args_preset.core.merge_custom({"web": {"http_headers": self.parsed.custom_headers}}) + + if self.parsed.custom_yara_rules: + args_preset.core.merge_custom( + {"modules": {"excavate": {"custom_yara_rules": self.parsed.custom_yara_rules}}} + ) + + # CLI config options (dot-syntax) + for config_arg in self.parsed.config: + try: + # if that fails, try to parse as key=value syntax + args_preset.core.merge_custom(OmegaConf.from_cli([config_arg])) + except Exception as e: + raise BBOTArgumentError(f'Error parsing command-line config option: "{config_arg}": {e}') + + return args_preset + + def create_parser(self, *args, **kwargs): + kwargs.update( + dict( + description="Bighuge BLS OSINT Tool", formatter_class=argparse.RawTextHelpFormatter, epilog=self.epilog + ) + ) + p = argparse.ArgumentParser(*args, **kwargs) + + target = p.add_argument_group(title="Target") + target.add_argument( + "-t", "--targets", nargs="+", default=[], help="Targets to seed the scan", metavar="TARGET" + ) + target.add_argument( + "-w", + "--whitelist", + nargs="+", + default=None, + help="What's considered in-scope (by default it's the 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", + ) + presets = p.add_argument_group(title="Presets") + presets.add_argument( + "-p", + "--preset", + nargs="*", + help="Enable BBOT preset(s)", + metavar="PRESET", + default=[], + ) + presets.add_argument( + "-c", + "--config", + nargs="*", + help="Custom config options in key=value format: e.g. 'modules.shodan.api_key=1234'", + metavar="CONFIG", + default=[], + ) + presets.add_argument("-lp", "--list-presets", action="store_true", help=f"List available presets.") + + modules = p.add_argument_group(title="Modules") + modules.add_argument( + "-m", + "--modules", + nargs="+", + default=[], + help=f'Modules to enable. Choices: {",".join(self.preset.module_loader.scan_module_choices)}', + metavar="MODULE", + ) + modules.add_argument("-l", "--list-modules", action="store_true", help=f"List available modules.") + modules.add_argument( + "-lmo", "--list-module-options", action="store_true", help="Show all module config options" + ) + modules.add_argument( + "-em", "--exclude-modules", nargs="+", default=[], help=f"Exclude these modules.", metavar="MODULE" + ) + modules.add_argument( + "-f", + "--flags", + nargs="+", + default=[], + help=f'Enable modules by flag. Choices: {",".join(self.preset.module_loader.flag_choices)}', + metavar="FLAG", + ) + modules.add_argument("-lf", "--list-flags", action="store_true", help=f"List available flags.") + modules.add_argument( + "-rf", + "--require-flags", + nargs="+", + default=[], + help=f"Only enable modules with these flags (e.g. -rf passive)", + metavar="FLAG", + ) + modules.add_argument( + "-ef", + "--exclude-flags", + nargs="+", + default=[], + help=f"Disable modules with these flags. (e.g. -ef aggressive)", + metavar="FLAG", + ) + modules.add_argument("--allow-deadly", action="store_true", help="Enable the use of highly aggressive modules") + + scan = p.add_argument_group(title="Scan") + 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( + "--force", + action="store_true", + help="Run scan even in the case of condition violations or failed module setups", + ) + scan.add_argument("-y", "--yes", action="store_true", help="Skip scan confirmation prompt") + scan.add_argument("--dry-run", action="store_true", help=f"Abort before executing scan") + scan.add_argument( + "--current-preset", + action="store_true", + help="Show the current preset in YAML format", + ) + scan.add_argument( + "--current-preset-full", + action="store_true", + help="Show the current preset in its full form, including defaults", + ) + + output = p.add_argument_group(title="Output") + output.add_argument( + "-o", + "--output-dir", + help="Directory to output scan results", + metavar="DIR", + ) + output.add_argument( + "-om", + "--output-modules", + nargs="+", + default=[], + help=f'Output module(s). Choices: {",".join(self.preset.module_loader.output_module_choices)}', + metavar="MODULE", + ) + output.add_argument("--json", "-j", action="store_true", help="Output scan data in JSON format") + output.add_argument("--brief", "-br", action="store_true", help="Output only the data itself") + output.add_argument("--event-types", nargs="+", default=[], help="Choose which event types to display") + + deps = p.add_argument_group( + title="Module dependencies", description="Control how modules install their dependencies" + ) + 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") + g2.add_argument("--retry-deps", action="store_true", help="Try again to install failed module dependencies") + 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") + + misc = p.add_argument_group(title="Misc") + misc.add_argument("--version", action="store_true", help="show BBOT version and exit") + misc.add_argument( + "-H", + "--custom-headers", + nargs="+", + default=[], + help="List of custom headers as key value pairs (header=value).", + ) + misc.add_argument("--custom-yara-rules", "-cy", help="Add custom yara rules to excavate") + return p + + def sanitize_args(self): + # silent implies -y + if self.parsed.silent: + self.parsed.yes = True + # chain_lists allows either comma-separated or space-separated lists + self.parsed.modules = chain_lists(self.parsed.modules) + self.parsed.exclude_modules = chain_lists(self.parsed.exclude_modules) + self.parsed.output_modules = chain_lists(self.parsed.output_modules) + 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}" + ) + self.parsed.blacklist = chain_lists( + self.parsed.blacklist, try_files=True, msg="Reading blacklist from file: {filename}" + ) + self.parsed.flags = chain_lists(self.parsed.flags) + self.parsed.exclude_flags = chain_lists(self.parsed.exclude_flags) + self.parsed.require_flags = chain_lists(self.parsed.require_flags) + self.parsed.event_types = [t.upper() for t in chain_lists(self.parsed.event_types)] + + # Custom Header Parsing / Validation + custom_headers_dict = {} + custom_header_example = "Example: --custom-headers foo=bar foo2=bar2" + + for i in self.parsed.custom_headers: + parts = i.split("=", 1) + if len(parts) != 2: + raise ValidationError(f"Custom headers not formatted correctly (missing '='). {custom_header_example}") + k, v = parts + if not k or not v: + raise ValidationError( + f"Custom headers not formatted correctly (missing header name or value). {custom_header_example}" + ) + custom_headers_dict[k] = v + self.parsed.custom_headers = custom_headers_dict + + def validate(self): + # validate config options + sentinel = object() + all_options = set(get_keys_in_dot_syntax(self.preset.core.default_config)) + for c in self.parsed.config: + c = c.split("=")[0].strip() + v = OmegaConf.select(self.preset.core.default_config, c, default=sentinel) + # if option isn't in the default config + if v is sentinel: + # skip if it's excluded from validation + if self.exclude_from_validation.match(c): + continue + # otherwise, ensure it exists as a module option + raise ValidationError(get_closest_match(c, all_options, msg="config option")) diff --git a/bbot/scanner/preset/conditions.py b/bbot/scanner/preset/conditions.py new file mode 100644 index 000000000..261a5c76e --- /dev/null +++ b/bbot/scanner/preset/conditions.py @@ -0,0 +1,54 @@ +import logging + +from bbot.errors import * + +log = logging.getLogger("bbot.preset.conditions") + +JINJA_ENV = None + + +class ConditionEvaluator: + def __init__(self, preset): + self.preset = preset + + @property + def context(self): + return { + "preset": self.preset, + "config": self.preset.config, + "abort": self.abort, + "warn": self.warn, + } + + def abort(self, message): + if not self.preset.force_start: + raise PresetAbortError(message) + + def warn(self, message): + log.warning(message) + + def evaluate(self): + context = self.context + already_evaluated = set() + for preset_name, condition in self.preset.conditions: + condition_str = str(condition) + if condition_str not in already_evaluated: + already_evaluated.add(condition_str) + try: + self.check_condition(condition_str, context) + except PresetAbortError as e: + raise PresetAbortError(f'Preset "{preset_name}" requested abort: {e} (--force to override)') + + @property + def jinja_env(self): + from jinja2.sandbox import SandboxedEnvironment + + global JINJA_ENV + if JINJA_ENV is None: + JINJA_ENV = SandboxedEnvironment() + return JINJA_ENV + + def check_condition(self, condition_str, context): + log.debug(f'Evaluating condition "{repr(condition_str)}"') + template = self.jinja_env.from_string(condition_str) + template.render(context) diff --git a/bbot/scanner/preset/environ.py b/bbot/scanner/preset/environ.py new file mode 100644 index 000000000..c4c2b8f5b --- /dev/null +++ b/bbot/scanner/preset/environ.py @@ -0,0 +1,136 @@ +import os +import sys +import omegaconf +from pathlib import Path + +from bbot.core.helpers.misc import cpu_architecture, os_platform, os_platform_friendly + + +def increase_limit(new_limit): + try: + import resource + + # Get current limit + soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE) + + new_limit = min(new_limit, hard_limit) + + # Attempt to set new limit + resource.setrlimit(resource.RLIMIT_NOFILE, (new_limit, hard_limit)) + except Exception as e: + sys.stderr.write(f"Failed to set new ulimit: {e}\n") + + +increase_limit(65535) + + +# Custom custom omegaconf resolver to get environment variables +def env_resolver(env_name, default=None): + return os.getenv(env_name, default) + + +def add_to_path(v, k="PATH", environ=None): + """ + Add an entry to a colon-separated PATH variable. + If it's already contained in the value, shift it to be in first position. + """ + if environ is None: + environ = os.environ + var_list = os.environ.get(k, "").split(":") + deduped_var_list = [] + for _ in var_list: + if _ != v and _ not in deduped_var_list: + deduped_var_list.append(_) + deduped_var_list = [v] + deduped_var_list + new_var_str = ":".join(deduped_var_list) + environ[k] = new_var_str + + +# if we're running in a virtual environment, make sure to include its /bin in PATH +if sys.prefix != sys.base_prefix: + bin_dir = str(Path(sys.prefix) / "bin") + add_to_path(bin_dir) + +# add ~/.local/bin to PATH +local_bin_dir = str(Path.home() / ".local" / "bin") +add_to_path(local_bin_dir) + + +# Register the new resolver +# this allows you to substitute environment variables in your config like "${env:PATH}"" +omegaconf.OmegaConf.register_new_resolver("env", env_resolver) + + +class BBOTEnviron: + + def __init__(self, preset): + self.preset = preset + + def flatten_config(self, config, base="bbot"): + """ + Flatten a JSON-like config into a list of environment variables: + {"modules": [{"httpx": {"timeout": 5}}]} --> "BBOT_MODULES_HTTPX_TIMEOUT=5" + """ + if type(config) == omegaconf.dictconfig.DictConfig: + for k, v in config.items(): + new_base = f"{base}_{k}" + if type(v) == omegaconf.dictconfig.DictConfig: + yield from self.flatten_config(v, base=new_base) + elif type(v) != omegaconf.listconfig.ListConfig: + yield (new_base.upper(), str(v)) + + def prepare(self): + """ + Sync config to OS environment variables + """ + environ = dict(os.environ) + + # ensure bbot_tools + environ["BBOT_TOOLS"] = str(self.preset.core.tools_dir) + add_to_path(str(self.preset.core.tools_dir), environ=environ) + # ensure bbot_cache + environ["BBOT_CACHE"] = str(self.preset.core.cache_dir) + # ensure bbot_temp + environ["BBOT_TEMP"] = str(self.preset.core.temp_dir) + # ensure bbot_lib + environ["BBOT_LIB"] = str(self.preset.core.lib_dir) + # export LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:~/.bbot/lib/ + add_to_path(str(self.preset.core.lib_dir), k="LD_LIBRARY_PATH", environ=environ) + + # platform variables + environ["BBOT_OS_PLATFORM"] = os_platform() + environ["BBOT_OS"] = os_platform_friendly() + environ["BBOT_CPU_ARCH"] = cpu_architecture() + + # copy config to environment + bbot_environ = self.flatten_config(self.preset.config) + environ.update(bbot_environ) + + # handle HTTP proxy + http_proxy = self.preset.config.get("http_proxy", "") + if http_proxy: + environ["HTTP_PROXY"] = http_proxy + environ["HTTPS_PROXY"] = http_proxy + else: + environ.pop("HTTP_PROXY", None) + environ.pop("HTTPS_PROXY", None) + + # ssl verification + import urllib3 + + urllib3.disable_warnings() + ssl_verify = self.preset.config.get("ssl_verify", False) + if not ssl_verify: + import requests + import functools + + requests.adapters.BaseAdapter.send = functools.partialmethod( + requests.adapters.BaseAdapter.send, verify=False + ) + requests.adapters.HTTPAdapter.send = functools.partialmethod( + requests.adapters.HTTPAdapter.send, verify=False + ) + requests.Session.request = functools.partialmethod(requests.Session.request, verify=False) + requests.request = functools.partial(requests.request, verify=False) + + return environ diff --git a/bbot/scanner/preset/path.py b/bbot/scanner/preset/path.py new file mode 100644 index 000000000..ee5235fbf --- /dev/null +++ b/bbot/scanner/preset/path.py @@ -0,0 +1,65 @@ +import logging +from pathlib import Path + +from bbot.errors import * + +log = logging.getLogger("bbot.presets.path") + +DEFAULT_PRESET_PATH = Path(__file__).parent.parent.parent / "presets" + + +class PresetPath: + """ + Keeps track of where to look for preset .yaml files + """ + + def __init__(self): + self.paths = [DEFAULT_PRESET_PATH] + + def find(self, filename): + filename_path = Path(filename).resolve() + extension = filename_path.suffix.lower() + file_candidates = set() + extension_candidates = {".yaml", ".yml"} + if extension: + extension_candidates.add(extension.lower()) + else: + file_candidates.add(filename_path.stem) + for ext in extension_candidates: + file_candidates.add(f"{filename_path.stem}{ext}") + file_candidates = sorted(file_candidates) + file_candidates_str = ",".join([str(s) for s in file_candidates]) + paths_to_search = self.paths + if "/" in str(filename): + if filename_path.parent not in paths_to_search: + paths_to_search.append(filename_path.parent) + log.debug(f"Searching for preset in {paths_to_search}, file candidates: {file_candidates_str}") + for path in paths_to_search: + for candidate in file_candidates: + for file in path.rglob(candidate): + log.verbose(f'Found preset matching "{filename}" at {file}') + self.add_path(file.parent) + return file.resolve() + raise ValidationError( + f'Could not find preset at "{filename}" - file does not exist. Use -lp to list available presets' + ) + + def __str__(self): + return ":".join([str(s) for s in self.paths]) + + def add_path(self, path): + path = Path(path).resolve() + if path in self.paths: + return + if any(path.is_relative_to(p) for p in self.paths): + return + if not path.is_dir(): + log.debug(f'Path "{path.resolve()}" is not a directory') + return + self.paths.append(path) + + def __iter__(self): + yield from self.paths + + +PRESET_PATH = PresetPath() diff --git a/bbot/scanner/preset/preset.py b/bbot/scanner/preset/preset.py new file mode 100644 index 000000000..ec72148e4 --- /dev/null +++ b/bbot/scanner/preset/preset.py @@ -0,0 +1,990 @@ +import os +import yaml +import logging +import omegaconf +import traceback +from copy import copy +from pathlib import Path +from contextlib import suppress + +from .path import PRESET_PATH + +from bbot.errors import * +from bbot.core import CORE +from bbot.core.helpers.misc import make_table, mkdir, get_closest_match + + +log = logging.getLogger("bbot.presets") + + +_preset_cache = dict() + + +# cache default presets to prevent having to reload from disk +DEFAULT_PRESETS = None + + +class Preset: + """ + A preset is the central config for a BBOT scan. It contains everything a scan needs to run -- + targets, modules, flags, config options like API keys, etc. + + You can create a preset manually and pass it into `Scanner(preset=preset)`. + Or, you can pass `Preset`'s kwargs into `Scanner()` and it will create the preset for you implicitly. + + Presets can include other presets (which can in turn include other presets, and so on). + This works by merging each preset in turn using `Preset.merge()`. + The order matters. In case of a conflict, the last preset to be merged wins priority. + + Presets can be loaded from or saved to YAML. BBOT has a number of ready-made presets for common tasks like + subdomain enumeration, web spidering, dirbusting, etc. + + Presets are highly customizable via `conditions`, which use the Jinja2 templating engine. + Using `conditions`, you can define custom logic to inspect the final preset before the scan starts, and change it if need be. + 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`). + blacklist (Target): Scan blacklist (this takes ultimate precedence). + strict_scope (bool): If True, subdomains of targets are not considered to be in-scope. + helpers (ConfigAwareHelper): Helper containing various reusable functions, regexes, etc. + output_dir (pathlib.Path): Output directory for scan. + scan_name (str): Name of scan. Defaults to random value, e.g. "demonic_jimmy". + name (str): Human-friendly name of preset. Used mainly for logging purposes. + description (str): Description of preset. + modules (set): Combined modules to enable for the scan. Includes scan modules, internal modules, and output modules. + scan_modules (set): Modules to enable for the scan. + output_modules (set): Output modules to enable for the scan. (note: if no output modules are specified, this is not populated until .bake()) + internal_modules (set): Internal modules for the scan. (note: not populated until .bake()) + exclude_modules (set): Modules to exclude from the scan. When set, automatically removes excluded modules. + flags (set): Flags to enable for the scan. When set, automatically enables modules. + require_flags (set): Require modules to have these flags. When set, automatically removes offending modules. + exclude_flags (set): Exclude modules that have any of these flags. When set, automatically removes offending modules. + module_dirs (set): Custom directories from which to load modules (alias to `self.module_loader.module_dirs`). When set, automatically preloads contained modules. + config (omegaconf.dictconfig.DictConfig): BBOT config (alias to `core.config`) + core (BBOTCore): Local copy of BBOTCore object. + verbose (bool): Whether log level is currently set to verbose. When set, updates log level for all BBOT log handlers. + debug (bool): Whether log level is currently set to debug. When set, updates log level for all BBOT log handlers. + silent (bool): Whether logging is currently disabled. When set to True, silences all stderr. + + Examples: + >>> preset = Preset( + "evilcorp.com", + "1.2.3.0/24", + flags=["subdomain-enum"], + modules=["nuclei"], + config={"http_proxy": "http://127.0.0.1"} + ) + >>> scan = Scanner(preset=preset) + + >>> preset = Preset.from_yaml_file("my_preset.yml") + >>> scan = Scanner(preset=preset) + """ + + def __init__( + self, + *targets, + whitelist=None, + blacklist=None, + strict_scope=False, + modules=None, + output_modules=None, + exclude_modules=None, + flags=None, + require_flags=None, + exclude_flags=None, + config=None, + module_dirs=None, + include=None, + output_dir=None, + scan_name=None, + name=None, + description=None, + conditions=None, + force_start=False, + verbose=False, + debug=False, + silent=False, + _exclude=None, + _log=True, + ): + """ + 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`. + blacklist (list, optional): Blacklisted target(s). Takes ultimate precedence. Defaults to empty. + strict_scope (bool, optional): If True, subdomains of targets are not in-scope. + 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. + exclude_modules (list[str], optional): List of modules to exclude from the scan. + require_flags (list[str], optional): Only enable modules if they have these flags. + exclude_flags (list[str], optional): Don't enable modules if they have any of these flags. + module_dirs (list[str], optional): additional directories to load modules from. + config (dict, optional): Additional scan configuration settings. + include (list[str], optional): names or filenames of other presets to include. + output_dir (str or Path, optional): Directory to store scan output. Defaults to BBOT home directory (`~/.bbot`). + scan_name (str, optional): Human-readable name of the scan. If not specified, it will be random, e.g. "demonic_jimmy". + name (str, optional): Human-readable name of the preset. Used mainly for logging. + description (str, optional): Description of the preset. + conditions (list[str], optional): Custom conditions to be executed before scan start. Written in Jinja2. + force_start (bool, optional): If True, ignore conditional aborts and failed module setups. Just run the scan! + verbose (bool, optional): Set the BBOT logger to verbose mode. + debug (bool, optional): Set the BBOT logger to debug mode. + silent (bool, optional): Silence all stderr (effectively disables the BBOT logger). + _exclude (list[Path], optional): Preset filenames to exclude from inclusion. Used internally to prevent infinite recursion in circular or self-referencing presets. + _log (bool, optional): Whether to enable logging for the preset. This will record which modules/flags are enabled, etc. + """ + # internal variables + self._cli = False + self._log = _log + self.scan = None + self._args = None + self._environ = None + self._helpers = None + self._module_loader = None + self._yaml_str = "" + self._verbose = False + self._debug = False + self._silent = False + self._baked = False + + self._default_output_modules = None + self._default_internal_modules = None + + # modules / flags + self.modules = set() + self.exclude_modules = set() + self.flags = set() + self.exclude_flags = set() + self.require_flags = set() + + # modules + flags + if modules is None: + modules = [] + if isinstance(modules, str): + modules = [modules] + if output_modules is None: + output_modules = [] + if isinstance(output_modules, str): + output_modules = [output_modules] + if exclude_modules is None: + exclude_modules = [] + if isinstance(exclude_modules, str): + exclude_modules = [exclude_modules] + if flags is None: + flags = [] + if isinstance(flags, str): + flags = [flags] + if exclude_flags is None: + exclude_flags = [] + if isinstance(exclude_flags, str): + exclude_flags = [exclude_flags] + if require_flags is None: + require_flags = [] + if isinstance(require_flags, str): + require_flags = [require_flags] + + # these are used only for preserving the modules as specified in the original preset + # this is to ensure the preset looks the same when reserialized + self.explicit_scan_modules = set() if modules is None else set(modules) + self.explicit_output_modules = set() if output_modules is None else set(output_modules) + + # whether to force-start the scan (ignoring conditional aborts and failed module setups) + self.force_start = force_start + + # scan output directory + self.output_dir = output_dir + # name of scan + self.scan_name = scan_name + + # name of preset, default blank + self.name = name or "" + # preset description, default blank + self.description = description or "" + + # custom conditions, evaluated during .bake() + self.conditions = [] + if conditions is not None: + for condition in conditions: + self.conditions.append((self.name, condition)) + + # keeps track of loaded preset files to prevent infinite circular inclusions + self._preset_files_loaded = set() + if _exclude is not None: + for _filename in _exclude: + self._preset_files_loaded.add(Path(_filename).resolve()) + + # bbot core config + self.core = CORE.copy() + if config is None: + config = omegaconf.OmegaConf.create({}) + # merge custom configs if specified by the user + self.core.merge_custom(config) + + # log verbosity + # setting these automatically sets the log level for all log handlers. + if verbose: + self.verbose = verbose + if debug: + self.debug = debug + if silent: + self.silent = silent + + # custom module directories + self._module_dirs = set() + self.module_dirs = module_dirs + + # target / whitelist / blacklist + self.strict_scope = strict_scope + # 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._blacklist = set(blacklist if blacklist else []) + + self._target = None + + # include other presets + if include and not isinstance(include, (list, tuple, set)): + include = [include] + if include: + for included_preset in include: + self.include_preset(included_preset) + + # we don't fill self.modules yet (that happens in .bake()) + self.explicit_scan_modules.update(set(modules)) + self.explicit_output_modules.update(set(output_modules)) + self.exclude_modules.update(set(exclude_modules)) + self.flags.update(set(flags)) + self.exclude_flags.update(set(exclude_flags)) + self.require_flags.update(set(require_flags)) + + @property + def bbot_home(self): + return Path(self.config.get("home", "~/.bbot")).expanduser().resolve() + + @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 whitelist(self): + if self._target is None: + raise ValueError("Cannot access whitelist before preset is baked (use ._whitelist instead)") + return self.target.whitelist + + @property + def blacklist(self): + if self._target is None: + raise ValueError("Cannot access blacklist before preset is baked (use ._blacklist instead)") + return self.target.blacklist + + @property + def preset_dir(self): + return self.bbot_home / "presets" + + @property + def default_output_modules(self): + if self._default_output_modules is not None: + output_modules = self._default_output_modules + else: + output_modules = ["python", "csv", "txt", "json"] + if self._cli: + output_modules.append("stdout") + return output_modules + + @property + def default_internal_modules(self): + preloaded_internal = self.module_loader.preloaded(type="internal") + if self._default_internal_modules is not None: + internal_modules = self._default_internal_modules + else: + internal_modules = list(preloaded_internal) + return {k: preloaded_internal[k] for k in internal_modules} + + def merge(self, other): + """ + Merge another preset into this one. + + If there are any config conflicts, `other` will win over `self`. + + Args: + other (Preset): The preset to merge into this one. + + Examples: + >>> preset1 = Preset(modules=["portscan"]) + >>> preset1.scan_modules + ['portscan'] + >>> preset2 = Preset(modules=["sslcert"]) + >>> preset2.scan_modules + ['sslcert'] + >>> preset1.merge(preset2) + >>> preset1.scan_modules + ['portscan', 'sslcert'] + """ + self.log_debug(f'Merging preset "{other.name}" into "{self.name}"') + # config + self.core.merge_custom(other.core.custom_config) + self.module_loader.core = self.core + # module dirs + # modules + flags + # establish requirements / exclusions first + self.exclude_modules.update(other.exclude_modules) + self.require_flags.update(other.require_flags) + self.exclude_flags.update(other.exclude_flags) + # then it's okay to start enabling modules + self.explicit_scan_modules.update(other.explicit_scan_modules) + self.explicit_output_modules.update(other.explicit_output_modules) + 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) + else: + self._whitelist.update(other._whitelist) + self._blacklist.update(other._blacklist) + self.strict_scope = self.strict_scope or other.strict_scope + + # log verbosity + if other.silent: + self.silent = other.silent + if other.verbose: + self.verbose = other.verbose + if other.debug: + self.debug = other.debug + # scan name + if other.scan_name is not None: + self.scan_name = other.scan_name + if other.output_dir is not None: + self.output_dir = other.output_dir + # conditions + if other.conditions: + self.conditions.extend(other.conditions) + # misc + self.force_start = self.force_start | other.force_start + self._cli = self._cli | other._cli + + def bake(self, scan=None): + """ + Return a "baked" copy of this preset, ready for use by a BBOT scan. + + 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. + """ + self.log_debug("Getting baked") + # create a copy of self + baked_preset = copy(self) + baked_preset.scan = scan + # copy core + baked_preset.core = self.core.copy() + # copy module loader + baked_preset._module_loader = self.module_loader.copy() + # prepare os environment + os_environ = baked_preset.environ.prepare() + # find and replace preloaded modules with os environ + # this is different from the config variable substitution because it modifies + # the preloaded modules, i.e. their ansible playbooks + baked_preset.module_loader.find_and_replace(**os_environ) + # update os environ + os.environ.clear() + os.environ.update(os_environ) + + # validate flags, config options + baked_preset.validate() + + # assign baked preset to our scan + if scan is not None: + scan.preset = baked_preset + + # now that our requirements / exclusions are validated, we can start enabling modules + # enable scan modules + for module in baked_preset.explicit_scan_modules: + baked_preset.add_module(module, module_type="scan") + + # enable output modules + output_modules_to_enable = baked_preset.explicit_output_modules + output_module_override = any(m in self.default_output_modules for m in output_modules_to_enable) + # if none of the default output modules have been explicitly specified, enable them all + if not output_module_override: + output_modules_to_enable.update(self.default_output_modules) + for module in output_modules_to_enable: + baked_preset.add_module(module, module_type="output", raise_error=False) + + # enable internal modules + for internal_module, preloaded in self.default_internal_modules.items(): + is_enabled = baked_preset.config.get(internal_module, True) + is_excluded = internal_module in baked_preset.exclude_modules + if is_enabled and not is_excluded: + baked_preset.add_module(internal_module, module_type="internal", raise_error=False) + + # disable internal modules if requested + for internal_module in baked_preset.internal_modules: + if baked_preset.config.get(internal_module, True) == False: + baked_preset.exclude_modules.add(internal_module) + + # enable modules by flag + for flag in baked_preset.flags: + for module, preloaded in baked_preset.module_loader.preloaded().items(): + module_flags = preloaded.get("flags", []) + module_type = preloaded.get("type", "scan") + if flag in module_flags: + self.log_debug(f'Enabling module "{module}" because it has flag "{flag}"') + baked_preset.add_module(module, module_type, raise_error=False) + + # ensure we have output modules + if not baked_preset.output_modules: + for output_module in self.default_output_modules: + baked_preset.add_module(output_module, module_type="output", raise_error=False) + + # create target object + from bbot.scanner.target import BBOTTarget + + baked_preset._target = BBOTTarget( + *list(self._seeds), + whitelist=self._whitelist, + blacklist=self._blacklist, + strict_scope=self.strict_scope, + scan=scan, + ) + + # evaluate conditions + if baked_preset.conditions: + from .conditions import ConditionEvaluator + + evaluator = ConditionEvaluator(baked_preset) + evaluator.evaluate() + + self._baked = True + return baked_preset + + def parse_args(self): + """ + Parse CLI arguments, and merge them into this preset. + + Used in `cli.py`. + """ + self._cli = True + self.merge(self.args.preset_from_args()) + + @property + def module_dirs(self): + return self.module_loader.module_dirs + + @module_dirs.setter + def module_dirs(self, module_dirs): + if module_dirs: + if isinstance(module_dirs, str): + module_dirs = [module_dirs] + for m in module_dirs: + self.module_loader.add_module_dir(m) + self._module_dirs.add(m) + + @property + def scan_modules(self): + return [m for m in self.modules if self.preloaded_module(m).get("type", "scan") == "scan"] + + @property + def output_modules(self): + return [m for m in self.modules if self.preloaded_module(m).get("type", "scan") == "output"] + + @property + def internal_modules(self): + return [m for m in self.modules if self.preloaded_module(m).get("type", "scan") == "internal"] + + def add_module(self, module_name, module_type="scan", raise_error=True): + self.log_debug(f'Adding module "{module_name}" of type "{module_type}"') + is_valid, reason, preloaded = self._is_valid_module(module_name, module_type, raise_error=raise_error) + if not is_valid: + self.log_debug(f'Unable to add {module_type} module "{module_name}": {reason}') + return + self.modules.add(module_name) + for module_dep in preloaded.get("deps", {}).get("modules", []): + if module_dep != module_name and module_dep not in self.modules: + self.log_verbose(f'Adding module "{module_dep}" because {module_name} depends on it') + self.add_module(module_dep, raise_error=False) + + def preloaded_module(self, module): + return self.module_loader.preloaded()[module] + + @property + def config(self): + return self.core.config + + @property + def web_config(self): + return self.core.config.get("web", {}) + + @property + def verbose(self): + return self._verbose + + @verbose.setter + def verbose(self, value): + if value: + self._debug = False + self._silent = False + self.core.logger.log_level = "VERBOSE" + else: + with suppress(omegaconf.errors.ConfigKeyError): + del self.core.custom_config["verbose"] + self.core.logger.log_level = "INFO" + self._verbose = value + + @property + def debug(self): + return self._debug + + @debug.setter + def debug(self, value): + if value: + self._verbose = False + self._silent = False + self.core.logger.log_level = "DEBUG" + else: + with suppress(omegaconf.errors.ConfigKeyError): + del self.core.custom_config["debug"] + self.core.logger.log_level = "INFO" + self._debug = value + + @property + def silent(self): + return self._silent + + @silent.setter + def silent(self, value): + if value: + self._verbose = False + self._debug = False + self.core.logger.log_level = "CRITICAL" + else: + with suppress(omegaconf.errors.ConfigKeyError): + del self.core.custom_config["silent"] + self.core.logger.log_level = "INFO" + self._silent = value + + @property + def helpers(self): + if self._helpers is None: + from bbot.core.helpers.helper import ConfigAwareHelper + + self._helpers = ConfigAwareHelper(preset=self) + return self._helpers + + @property + def module_loader(self): + self.environ + if self._module_loader is None: + from bbot.core.modules import MODULE_LOADER + + self._module_loader = MODULE_LOADER + self._module_loader.ensure_config_files() + + return self._module_loader + + @property + def environ(self): + if self._environ is None: + from .environ import BBOTEnviron + + self._environ = BBOTEnviron(self) + return self._environ + + @property + def args(self): + if self._args is None: + from .args import BBOTArgs + + self._args = BBOTArgs(self) + return self._args + + def in_scope(self, host): + return self.target.in_scope(host) + + def blacklisted(self, host): + return self.target.blacklisted(host) + + def whitelisted(self, host): + return self.target.whitelisted(host) + + @classmethod + def from_dict(cls, preset_dict, name=None, _exclude=None, _log=False): + """ + Create a preset from a Python dictionary object. + + Args: + preset_dict (dict): Preset in dictionary form + name (str, optional): Name of preset + _exclude (list[Path], optional): Preset filenames to exclude from inclusion. Used internally to prevent infinite recursion in circular or self-referencing presets. + _log (bool, optional): Whether to enable logging for the preset. This will record which modules/flags are enabled, etc. + + Returns: + Preset: The loaded preset + + Examples: + >>> preset = Preset.from_dict({"target": ["evilcorp.com"], "modules": ["portscan"]}) + """ + new_preset = cls( + *preset_dict.get("target", []), + whitelist=preset_dict.get("whitelist"), + blacklist=preset_dict.get("blacklist"), + modules=preset_dict.get("modules"), + output_modules=preset_dict.get("output_modules"), + exclude_modules=preset_dict.get("exclude_modules"), + flags=preset_dict.get("flags"), + require_flags=preset_dict.get("require_flags"), + exclude_flags=preset_dict.get("exclude_flags"), + verbose=preset_dict.get("verbose", False), + debug=preset_dict.get("debug", False), + silent=preset_dict.get("silent", False), + config=preset_dict.get("config"), + strict_scope=preset_dict.get("strict_scope", False), + module_dirs=preset_dict.get("module_dirs", []), + include=list(preset_dict.get("include", [])), + scan_name=preset_dict.get("scan_name"), + output_dir=preset_dict.get("output_dir"), + name=preset_dict.get("name", name), + description=preset_dict.get("description"), + conditions=preset_dict.get("conditions", []), + _exclude=_exclude, + _log=_log, + ) + return new_preset + + def include_preset(self, filename): + """ + Load a preset from a yaml file and merge it into this one. + + If the full path is not specified, BBOT will look in all the usual places for it. + + The file extension is optional. + + Args: + filename (Path): The preset YAML file to merge + + Examples: + >>> preset.include_preset("/home/user/my_preset.yml") + """ + self.log_debug(f'Including preset "{filename}"') + preset_filename = PRESET_PATH.find(filename) + preset_from_yaml = self.from_yaml_file(preset_filename, _exclude=self._preset_files_loaded) + if preset_from_yaml is not False: + self.merge(preset_from_yaml) + self._preset_files_loaded.add(preset_filename) + + @classmethod + def from_yaml_file(cls, filename, _exclude=None, _log=False): + """ + Create a preset from a YAML file. If the full path is not specified, BBOT will look in all the usual places for it. + + The file extension is optional. + + Examples: + >>> preset = Preset.from_yaml_file("/home/user/my_preset.yml") + """ + filename = Path(filename).resolve() + try: + return _preset_cache[filename] + except KeyError: + if _exclude is None: + _exclude = set() + if _exclude is not None and filename in _exclude: + log.debug(f"Not loading {filename} because it was already loaded {_exclude}") + return False + log.debug(f"Loading {filename} because it's not in excluded list ({_exclude})") + _exclude = set(_exclude) + _exclude.add(filename) + try: + yaml_str = open(filename).read() + except FileNotFoundError: + raise PresetNotFoundError(f'Could not find preset at "{filename}" - file does not exist') + preset = cls.from_dict( + omegaconf.OmegaConf.create(yaml_str), name=filename.stem, _exclude=_exclude, _log=_log + ) + preset._yaml_str = yaml_str + _preset_cache[filename] = preset + return preset + + @classmethod + def from_yaml_string(cls, yaml_preset): + """ + Create a preset from a YAML file. If the full path is not specified, BBOT will look in all the usual places for it. + + The file extension is optional. + + Examples: + >>> yaml_string = ''' + >>> target: + >>> - evilcorp.com + >>> modules: + >>> - portscan''' + >>> preset = Preset.from_yaml_string(yaml_string) + """ + return cls.from_dict(omegaconf.OmegaConf.create(yaml_preset)) + + 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 + full_config (bool, optional): If True, include the entire config, not just what's changed from the defaults. + + Returns: + dict: The preset in dictionary form + + Examples: + >>> preset = Preset(flags=["subdomain-enum"], modules=["portscan"]) + >>> preset.to_dict() + {"flags": ["subdomain-enum"], "modules": ["portscan"]} + """ + preset_dict = {} + + # config + if full_config: + config = self.core.config + else: + config = self.core.custom_config + config = omegaconf.OmegaConf.to_object(config) + if redact_secrets: + config = self.core.no_secrets_config(config) + if config: + preset_dict["config"] = config + + # scope + if include_target: + target = sorted(str(t.data) for t in self.target.seeds) + whitelist = [] + if self.target.whitelist is not None: + whitelist = sorted(str(t.data) for t in self.target.whitelist) + blacklist = sorted(str(t.data) for t in self.target.blacklist) + if target: + preset_dict["target"] = target + if whitelist and whitelist != target: + preset_dict["whitelist"] = whitelist + if blacklist: + preset_dict["blacklist"] = blacklist + if self.strict_scope: + preset_dict["strict_scope"] = True + + # flags + modules + if self.require_flags: + preset_dict["require_flags"] = sorted(self.require_flags) + if self.exclude_flags: + preset_dict["exclude_flags"] = sorted(self.exclude_flags) + if self.exclude_modules: + preset_dict["exclude_modules"] = sorted(self.exclude_modules) + if self.flags: + preset_dict["flags"] = sorted(self.flags) + if self.explicit_scan_modules: + preset_dict["modules"] = sorted(self.explicit_scan_modules) + if self.explicit_output_modules: + preset_dict["output_modules"] = sorted(self.explicit_output_modules) + + # log verbosity + if self.verbose: + preset_dict["verbose"] = True + if self.debug: + preset_dict["debug"] = True + if self.silent: + preset_dict["silent"] = True + + # misc scan options + if self.scan_name: + preset_dict["scan_name"] = self.scan_name + if self.scan_name: + preset_dict["output_dir"] = self.output_dir + + # conditions + if self.conditions: + preset_dict["conditions"] = [c[-1] for c in self.conditions] + + return preset_dict + + 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 + 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 + + Returns: + str: The preset in the form of a YAML string + + Examples: + >>> preset = Preset(flags=["subdomain-enum"], modules=["portscan"]) + >>> print(preset.to_yaml()) + flags: + - subdomain-enum + modules: + - portscan + """ + preset_dict = self.to_dict(include_target=include_target, full_config=full_config) + return yaml.dump(preset_dict, sort_keys=sort_keys) + + def _is_valid_module(self, module, module_type, name_only=False, raise_error=True): + if module_type == "scan": + module_choices = self.module_loader.scan_module_choices + elif module_type == "output": + module_choices = self.module_loader.output_module_choices + elif module_type == "internal": + module_choices = self.module_loader.internal_module_choices + else: + raise ValidationError(f'Unknown module type "{module}"') + + if not module in module_choices: + raise ValidationError(get_closest_match(module, module_choices, msg=f"{module_type} module")) + + try: + preloaded = self.module_loader.preloaded()[module] + except KeyError: + raise ValidationError(f'Unknown module "{module}"') + + if name_only: + return True, "", preloaded + + if module in self.exclude_modules: + reason = "the module has been excluded" + if raise_error: + raise ValidationError(f'Unable to add {module_type} module "{module}" because {reason}') + return False, reason, {} + + module_flags = preloaded.get("flags", []) + _module_type = preloaded.get("type", "scan") + if module_type: + if _module_type != module_type: + reason = f'its type ({_module_type}) is not "{module_type}"' + if raise_error: + raise ValidationError(f'Unable to add {module_type} module "{module}" because {reason}') + return False, reason, preloaded + + if _module_type == "scan": + if self.exclude_flags: + for f in module_flags: + if f in self.exclude_flags: + return False, f'it has excluded flag, "{f}"', preloaded + if self.require_flags and not all(f in module_flags for f in self.require_flags): + return False, f'it doesn\'t have the required flags ({",".join(self.require_flags)})', preloaded + + return True, "", preloaded + + def validate(self): + """ + Validate module/flag exclusions/requirements, and CLI config options if applicable. + """ + if self._cli: + self.args.validate() + + # validate excluded modules + for excluded_module in self.exclude_modules: + if not excluded_module in self.module_loader.all_module_choices: + raise ValidationError( + get_closest_match(excluded_module, self.module_loader.all_module_choices, msg="module") + ) + # validate excluded flags + for excluded_flag in self.exclude_flags: + if not excluded_flag in self.module_loader.flag_choices: + raise ValidationError(get_closest_match(excluded_flag, self.module_loader.flag_choices, msg="flag")) + # validate required flags + for required_flag in self.require_flags: + if not required_flag in self.module_loader.flag_choices: + raise ValidationError(get_closest_match(required_flag, self.module_loader.flag_choices, msg="flag")) + # validate flags + for flag in self.flags: + if not flag in self.module_loader.flag_choices: + raise ValidationError(get_closest_match(flag, self.module_loader.flag_choices, msg="flag")) + + @property + def all_presets(self): + """ + Recursively find all the presets and return them as a dictionary + """ + preset_dir = self.preset_dir + home_dir = Path.home() + + # first, add local preset dir to PRESET_PATH + PRESET_PATH.add_path(self.preset_dir) + + # ensure local preset directory exists + mkdir(preset_dir) + + global DEFAULT_PRESETS + if DEFAULT_PRESETS is None: + presets = dict() + for ext in ("yml", "yaml"): + for preset_path in PRESET_PATH: + # for every yaml file + for original_filename in preset_path.rglob(f"**/*.{ext}"): + # not including symlinks + if original_filename.is_symlink(): + continue + + # try to load it as a preset + try: + loaded_preset = self.from_yaml_file(original_filename, _log=True) + if loaded_preset is False: + continue + except Exception as e: + log.warning(f'Failed to load preset at "{original_filename}": {e}') + log.trace(traceback.format_exc()) + continue + + # category is the parent folder(s), if any + category = str(original_filename.relative_to(preset_path).parent) + if category == ".": + category = "" + + local_preset = original_filename + # populate symlinks in local preset dir + if not original_filename.is_relative_to(preset_dir): + relative_preset = original_filename.relative_to(preset_path) + local_preset = preset_dir / relative_preset + mkdir(local_preset.parent, check_writable=False) + if not local_preset.exists(): + local_preset.symlink_to(original_filename) + + # collapse home directory into "~" + if local_preset.is_relative_to(home_dir): + local_preset = Path("~") / local_preset.relative_to(home_dir) + + presets[local_preset] = (loaded_preset, category, preset_path, original_filename) + + # sort by name + DEFAULT_PRESETS = dict(sorted(presets.items(), key=lambda x: x[-1][0].name)) + return DEFAULT_PRESETS + + def presets_table(self, include_modules=True): + """ + Return a table of all the presets in the form of a string + """ + table = [] + header = ["Preset", "Category", "Description", "# Modules"] + if include_modules: + header.append("Modules") + for yaml_file, (loaded_preset, category, preset_path, original_file) in self.all_presets.items(): + loaded_preset = loaded_preset.bake() + num_modules = f"{len(loaded_preset.scan_modules):,}" + row = [loaded_preset.name, category, loaded_preset.description, num_modules] + if include_modules: + row.append(", ".join(sorted(loaded_preset.scan_modules))) + table.append(row) + return make_table(table, header) + + def log_verbose(self, msg): + if self._log: + log.verbose(f"Preset {self.name}: {msg}") + + def log_debug(self, msg): + if self._log: + log.debug(f"Preset {self.name}: {msg}") diff --git a/bbot/scanner/scanner.py b/bbot/scanner/scanner.py index a9bae8620..b2d5f9a8b 100644 --- a/bbot/scanner/scanner.py +++ b/bbot/scanner/scanner.py @@ -1,57 +1,36 @@ -import re import sys import asyncio import logging import traceback import contextlib -from sys import exc_info +import regex as re from pathlib import Path -import multiprocessing as mp +from sys import exc_info from datetime import datetime -from functools import partial -from omegaconf import OmegaConf from collections import OrderedDict -from concurrent.futures import ProcessPoolExecutor from bbot import __version__ -from bbot import config as bbot_config -from .target import Target -from .stats import ScanStats -from .manager import ScanManager -from .dispatcher import Dispatcher -from bbot.modules import module_loader from bbot.core.event import make_event +from .manager import ScanIngress, ScanEgress from bbot.core.helpers.misc import sha1, rand_string -from bbot.core.helpers.helper import ConfigAwareHelper from bbot.core.helpers.names_generator import random_name from bbot.core.helpers.async_helpers import async_to_sync_gen -from bbot.core.configurator.environ import prepare_environment -from bbot.core.errors import BBOTError, ScanError, ValidationError -from bbot.core.logger import ( - init_logging, - get_log_level, - set_log_level, - add_log_handler, - get_log_handlers, - remove_log_handler, -) +from bbot.errors import BBOTError, ScanError, ValidationError log = logging.getLogger("bbot.scanner") -init_logging() - class Scanner: """A class representing a single BBOT scan Examples: Create scan with multiple targets: - >>> my_scan = Scanner("evilcorp.com", "1.2.3.0/24", modules=["nmap", "sslcert", "httpx"]) + >>> my_scan = Scanner("evilcorp.com", "1.2.3.0/24", modules=["portscan", "sslcert", "httpx"]) Create scan with custom config: - >>> config = {"http_proxy": "http://127.0.0.1:8080", "modules": {"nmap": {"top_ports": 2000}}} - >>> my_scan = Scanner("www.evilcorp.com", modules=["nmap", "httpx"], config=config) + >>> config = {"http_proxy": "http://127.0.0.1:8080", "modules": {"portscan": {"top_ports": 2000}}} + >>> my_scan = Scanner("www.evilcorp.com", modules=["portscan", "httpx"], config=config) Start the scan, iterating over events as they're discovered (synchronous): >>> for event in my_scan.start(): @@ -81,16 +60,18 @@ class Scanner: - "FINISHED" (8): Status when the scan has successfully completed. ``` _status_code (int): The numerical representation of the current scan status, stored for internal use. It is mapped according to the values in `_status_codes`. - target (Target): Target of scan - config (omegaconf.dictconfig.DictConfig): BBOT config - whitelist (Target): Scan whitelist (by default this is the same as `target`) - blacklist (Target): Scan blacklist (this takes ultimate precedence) - helpers (ConfigAwareHelper): Helper containing various reusable functions, regexes, etc. - manager (ScanManager): Coordinates and monitors the flow of events between modules during a scan - dispatcher (Dispatcher): Triggers certain events when the scan `status` changes - modules (dict): Holds all loaded modules in this format: `{"module_name": Module()}` - stats (ScanStats): Holds high-level scan statistics such as how many events have been produced and consumed by each module - home (pathlib.Path): Base output directory of the scan (default: `~/.bbot/scans/`) + 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`). + 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`). + name (str): Name of scan (alias to `self.preset.scan_name`). + dispatcher (Dispatcher): Triggers certain events when the scan `status` changes. + modules (dict): Holds all loaded modules in this format: `{"module_name": Module()}`. + stats (ScanStats): Holds high-level scan statistics such as how many events have been produced and consumed by each module. + home (pathlib.Path): Base output directory of the scan (default: `~/.bbot/scans/`). running (bool): Whether the scan is currently running. stopping (bool): Whether the scan is currently stopping. stopped (bool): Whether the scan is currently stopped. @@ -117,142 +98,128 @@ class Scanner: def __init__( self, *targets, - whitelist=None, - blacklist=None, scan_id=None, - name=None, - modules=None, - output_modules=None, - output_dir=None, - config=None, dispatcher=None, - strict_scope=False, - force_start=False, + **kwargs, ): """ Initializes the Scanner class. + If a premade `preset` is specified, it will be used for the scan. + Otherwise, `Scan` accepts the same arguments as `Preset`, which are passed through and used to create a new preset. + Args: - *targets (str): Target(s) to scan. - whitelist (list, optional): Whitelisted target(s) to scan. Defaults to the same as `targets`. - blacklist (list, optional): Blacklisted target(s). Takes ultimate precedence. Defaults to empty. + *targets (list[str], optional): Scan targets (passed through to `Preset`). + preset (Preset, optional): Preset to use for the scan. scan_id (str, optional): Unique identifier for the scan. Auto-generates if None. - name (str, optional): Human-readable name of the scan. Auto-generates if None. - modules (list[str], optional): List of module names to use during the scan. Defaults to empty list. - output_modules (list[str], optional): List of output modules to use. Defaults to ['python']. - output_dir (str or Path, optional): Directory to store scan output. Defaults to BBOT home directory (`~/.bbot`). - config (dict, optional): Configuration settings. Merged with BBOT config. dispatcher (Dispatcher, optional): Dispatcher object to use. Defaults to new Dispatcher. - strict_scope (bool, optional): If True, only targets explicitly in whitelist are scanned. Defaults to False. - force_start (bool, optional): If True, allows the scan to start even when module setups hard-fail. Defaults to False. + **kwargs (list[str], optional): Additional keyword arguments (passed through to `Preset`). """ - if modules is None: - modules = [] - if output_modules is None: - output_modules = ["python"] - - if isinstance(modules, str): - modules = [modules] - if isinstance(output_modules, str): - output_modules = [output_modules] - - if config is None: - config = OmegaConf.create({}) - else: - config = OmegaConf.create(config) - self.config = OmegaConf.merge(bbot_config, config) - prepare_environment(self.config) - if self.config.get("debug", False): - set_log_level(logging.DEBUG) - - self.strict_scope = strict_scope - self.force_start = force_start - if scan_id is not None: - self.id = str(scan_id) + self.id = str(id) else: self.id = f"SCAN:{sha1(rand_string(20)).hexdigest()}" - self._status = "NOT_STARTED" - self._status_code = 0 - self.max_workers = max(1, self.config.get("max_threads", 25)) - self.helpers = ConfigAwareHelper(config=self.config, scan=self) + preset = kwargs.pop("preset", None) + kwargs["_log"] = True - if name is None: + from .preset import Preset + + if preset is None: + preset = Preset(*targets, **kwargs) + else: + if not isinstance(preset, Preset): + raise ValidationError(f'Preset must be of type Preset, not "{type(preset).__name__}"') + self.preset = preset.bake(self) + + # scan name + if preset.scan_name is None: tries = 0 while 1: if tries > 5: - self.name = f"{self.helpers.rand_string(4)}_{self.helpers.rand_string(4)}" + scan_name = f"{rand_string(4)}_{rand_string(4)}" break - self.name = random_name() - if output_dir is not None: - home_path = Path(output_dir).resolve() / self.name + scan_name = random_name() + if self.preset.output_dir is not None: + home_path = Path(self.preset.output_dir).resolve() / scan_name else: - home_path = self.helpers.bbot_home / "scans" / self.name + home_path = self.preset.bbot_home / "scans" / scan_name if not home_path.exists(): break tries += 1 else: - self.name = str(name) + scan_name = str(preset.scan_name) + self.name = scan_name - if output_dir is not None: - self.home = Path(output_dir).resolve() / self.name + # scan output dir + if preset.output_dir is not None: + self.home = Path(preset.output_dir).resolve() / self.name else: - self.home = self.helpers.bbot_home / "scans" / self.name + self.home = self.preset.bbot_home / "scans" / self.name - self.target = Target(self, *targets, strict_scope=strict_scope, make_in_scope=True) + self._status = "NOT_STARTED" + self._status_code = 0 self.modules = OrderedDict({}) - self._scan_modules = modules - self._internal_modules = list(self._internal_modules()) - self._output_modules = output_modules self._modules_loaded = False - - if not whitelist: - self.whitelist = self.target.copy() - else: - self.whitelist = Target(self, *whitelist, strict_scope=strict_scope) - if not blacklist: - blacklist = [] - self.blacklist = Target(self, *blacklist) + self.dummy_modules = {} if dispatcher is None: + from .dispatcher import Dispatcher + self.dispatcher = Dispatcher() else: self.dispatcher = dispatcher self.dispatcher.set_scan(self) - self.manager = ScanManager(self) + from .stats import ScanStats + self.stats = ScanStats(self) # scope distance - self.scope_search_distance = max(0, int(self.config.get("scope_search_distance", 0))) - self.scope_dns_search_distance = max( - self.scope_search_distance, int(self.config.get("scope_dns_search_distance", 1)) - ) - self.scope_report_distance = int(self.config.get("scope_report_distance", 1)) + self.scope_config = self.config.get("scope", {}) + self.scope_search_distance = max(0, int(self.scope_config.get("search_distance", 0))) + 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) + 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") + # custom HTTP headers warning + self.custom_http_headers = self.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." + ) # url file extensions self.url_extension_blacklist = set(e.lower() for e in self.config.get("url_extension_blacklist", [])) self.url_extension_httpx_only = set(e.lower() for e in self.config.get("url_extension_httpx_only", [])) + # url querystring behavior + self.url_querystring_remove = self.config.get("url_querystring_remove", True) + # blob inclusion self._file_blobs = self.config.get("file_blobs", False) self._folder_blobs = self.config.get("folder_blobs", False) - # custom HTTP headers warning - self.custom_http_headers = self.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." - ) - # how often to print scan status self.status_frequency = self.config.get("status_frequency", 15) self._prepped = False self._finished_init = False + self._new_activity = False self._cleanedup = False + self._omitted_event_types = None self.__loop = None self._manager_worker_loop_tasks = [] @@ -260,18 +227,9 @@ def __init__( self.ticker_task = None self.dispatcher_tasks = [] - # multiprocessing thread pool - try: - mp.set_start_method("spawn") - except Exception: - self.warning(f"Failed to set multiprocessing spawn method. This may negatively affect performance.") - # we spawn 1 fewer processes than cores - # this helps to avoid locking up the system or competing with the main python process for cpu time - num_processes = max(1, mp.cpu_count() - 1) - self.process_pool = ProcessPoolExecutor(max_workers=num_processes) - self._stopping = False + self._dns_strings = None self._dns_regexes = None # temporary fix to boost scan performance # TODO: remove this when https://github.com/blacklanternsecurity/bbot/issues/1252 is merged @@ -282,12 +240,17 @@ def __init__( async def _prep(self): """ - Calls .load_modules() and .setup_modules() in preparation for a scan + Creates the scan's output folder, loads its modules, and calls their .setup() methods. """ self.helpers.mkdir(self.home) if not self._prepped: - start_msg = f"Scan with {len(self._scan_modules):,} modules seeded with {len(self.target):,} targets" + # save scan preset + with open(self.home / "preset.yml", "w") as f: + f.write(self.preset.to_yaml()) + + # log scan overview + start_msg = f"Scan with {len(self.preset.scan_modules):,} modules seeded with {len(self.target):,} targets" details = [] if self.whitelist != self.target: details.append(f"{len(self.whitelist):,} in whitelist") @@ -297,14 +260,27 @@ async def _prep(self): start_msg += f" ({', '.join(details)})" self.hugeinfo(start_msg) + # load scan modules (this imports and instantiates them) + # up to this point they were only preloaded await self.load_modules() - self.info(f"Setting up modules...") + # run each module's .setup() method succeeded, hard_failed, soft_failed = await self.setup_modules() + # intercept modules get sewn together like human centipede + self.intercept_modules = [m for m in self.modules.values() if m._intercept] + for i, intercept_module in enumerate(self.intercept_modules[1:]): + prev_intercept_module = self.intercept_modules[i] + self.debug( + f"Setting intercept module {intercept_module.name}._incoming_event_queue to previous intercept module {prev_intercept_module.name}.outgoing_event_queue" + ) + intercept_module._incoming_event_queue = prev_intercept_module.outgoing_event_queue + + # abort if there are no output modules num_output_modules = len([m for m in self.modules.values() if m._type == "output"]) if num_output_modules < 1: raise ScanError("Failed to load output modules. Aborting.") + # abort if any of the module .setup()s hard-failed (i.e. they errored or returned False) total_failed = len(hard_failed + soft_failed) if hard_failed: msg = f"Setup hard-failed for {len(hard_failed):,} modules ({','.join(hard_failed)})" @@ -355,18 +331,13 @@ async def async_start(self): await self.dispatcher.on_start(self) - # start manager worker loops - self._manager_worker_loop_tasks = [ - asyncio.create_task(self.manager._worker_loop()) for _ in range(self.max_workers) - ] - - # distribute seed events - self.init_events_task = asyncio.create_task(self.manager.init_events()) - self.status = "RUNNING" self._start_modules() self.verbose(f"{len(self.modules):,} modules started") + # distribute seed events + self.init_events_task = asyncio.create_task(self.ingress_module.init_events(self.target.events)) + # main scan loop while 1: # abort if we're aborting @@ -374,13 +345,14 @@ async def async_start(self): self._drain_queues() break + # yield events as they come (async for event in scan.async_start()) if "python" in self.modules: - events, finish = await self.modules["python"]._events_waiting() + events, finish = await self.modules["python"]._events_waiting(batch_size=-1) for e in events: yield e - # if initialization finished and the scan is no longer active - if self._finished_init and not self.manager.active: + # break if initialization finished and the scan is no longer active + if self._finished_init and self.modules_finished: new_activity = await self.finish() if not new_activity: break @@ -390,8 +362,7 @@ async def async_start(self): failed = False except BaseException as e: - exception_chain = self.helpers.get_exception_chain(e) - if any(isinstance(exc, (KeyboardInterrupt, asyncio.CancelledError)) for exc in exception_chain): + if self.helpers.in_exception_chain(e, (KeyboardInterrupt, asyncio.CancelledError)): self.stop() failed = False else: @@ -410,7 +381,7 @@ async def async_start(self): tasks = self._cancel_tasks() self.debug(f"Awaiting {len(tasks):,} tasks") for task in tasks: - self.debug(f"Awaiting {task}") + # self.debug(f"Awaiting {task}") with contextlib.suppress(BaseException): await task self.debug(f"Awaited {len(tasks):,} tasks") @@ -437,7 +408,7 @@ async def async_start(self): def _start_modules(self): self.verbose(f"Starting module worker loops") - for module_name, module in self.modules.items(): + for module in self.modules.values(): module.start() async def setup_modules(self, remove_failed=True): @@ -466,19 +437,20 @@ async def setup_modules(self, remove_failed=True): soft_failed = [] async for task in self.helpers.as_completed([m._setup() for m in self.modules.values()]): - module_name, status, msg = await task + module, status, msg = await task if status == True: - self.debug(f"Setup succeeded for {module_name} ({msg})") - succeeded.append(module_name) + self.debug(f"Setup succeeded for {module.name} ({msg})") + succeeded.append(module.name) elif status == False: - self.warning(f"Setup hard-failed for {module_name}: {msg}") - self.modules[module_name].set_error_state() - hard_failed.append(module_name) + self.warning(f"Setup hard-failed for {module.name}: {msg}") + self.modules[module.name].set_error_state() + hard_failed.append(module.name) else: - self.info(f"Setup soft-failed for {module_name}: {msg}") - soft_failed.append(module_name) - if not status and remove_failed: - self.modules.pop(module_name) + self.info(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 + self.modules.pop(module.name) return succeeded, hard_failed, soft_failed @@ -493,7 +465,7 @@ async def load_modules(self): 4. Load output modules and updates the `modules` dictionary. 5. Sorts modules based on their `_priority` attribute. - If any modules fail to load or their dependencies fail to install, a ScanError will be raised (unless `self.force_start` is set to True). + If any modules fail to load or their dependencies fail to install, a ScanError will be raised (unless `self.force_start` is True). Attributes: succeeded, failed (tuple): A tuple containing lists of modules that succeeded or failed during the dependency installation. @@ -501,7 +473,7 @@ async def load_modules(self): failed, failed_internal, failed_output (list): Lists of module names that failed to load. Raises: - ScanError: If any module dependencies fail to install or modules fail to load, and if self.force_start is False. + ScanError: If any module dependencies fail to install or modules fail to load, and if `self.force_start` is False. Returns: None @@ -510,24 +482,21 @@ 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: - all_modules = list(set(self._scan_modules + self._output_modules + self._internal_modules)) - if not all_modules: + if not self.preset.modules: self.warning(f"No modules to load") return - if not self._scan_modules: + if not self.preset.scan_modules: self.warning(f"No scan modules to load") # install module dependencies - succeeded, failed = await self.helpers.depsinstaller.install( - *self._scan_modules, *self._output_modules, *self._internal_modules - ) + succeeded, failed = await self.helpers.depsinstaller.install(*self.preset.modules) if failed: msg = f"Failed to install dependencies for {len(failed):,} modules: {','.join(failed)}" self._fail_setup(msg) - modules = sorted([m for m in self._scan_modules if m in succeeded]) - output_modules = sorted([m for m in self._output_modules if m in succeeded]) - internal_modules = sorted([m for m in self._internal_modules if m in succeeded]) + modules = sorted([m for m in self.preset.scan_modules if m in succeeded]) + output_modules = sorted([m for m in self.preset.output_modules if m in succeeded]) + internal_modules = sorted([m for m in self.preset.internal_modules if m in succeeded]) # Load scan modules self.verbose(f"Loading {len(modules):,} scan modules: {','.join(modules)}") @@ -538,7 +507,7 @@ async def load_modules(self): self._fail_setup(msg) if loaded_modules: self.info( - f"Loaded {len(loaded_modules):,}/{len(self._scan_modules):,} scan modules ({','.join(loaded_modules)})" + f"Loaded {len(loaded_modules):,}/{len(self.preset.scan_modules):,} scan modules ({','.join(loaded_modules)})" ) # Load internal modules @@ -550,7 +519,7 @@ async def load_modules(self): self._fail_setup(msg) if loaded_internal_modules: self.info( - f"Loaded {len(loaded_internal_modules):,}/{len(self._internal_modules):,} internal modules ({','.join(loaded_internal_modules)})" + f"Loaded {len(loaded_internal_modules):,}/{len(self.preset.internal_modules):,} internal modules ({','.join(loaded_internal_modules)})" ) # Load output modules @@ -562,12 +531,151 @@ async def load_modules(self): self._fail_setup(msg) if loaded_output_modules: self.info( - f"Loaded {len(loaded_output_modules):,}/{len(self._output_modules):,} output modules, ({','.join(loaded_output_modules)})" + f"Loaded {len(loaded_output_modules):,}/{len(self.preset.output_modules):,} output modules, ({','.join(loaded_output_modules)})" ) - self.modules = OrderedDict(sorted(self.modules.items(), key=lambda x: getattr(x[-1], "_priority", 0))) + # builtin intercept modules + self.ingress_module = ScanIngress(self) + self.egress_module = ScanEgress(self) + self.modules[self.ingress_module.name] = self.ingress_module + self.modules[self.egress_module.name] = self.egress_module + + # sort modules by priority + self.modules = OrderedDict(sorted(self.modules.items(), key=lambda x: getattr(x[-1], "priority", 3))) + self._modules_loaded = True + @property + def modules_finished(self): + finished_modules = [m.finished for m in self.modules.values()] + return all(finished_modules) + + def kill_module(self, module_name, message=None): + from signal import SIGINT + + module = self.modules[module_name] + if module._intercept: + self.warning(f'Cannot kill module "{module_name}" because it is critical to the scan') + return + module.set_error_state(message=message, clear_outgoing_queue=True) + for proc in module._proc_tracker: + with contextlib.suppress(Exception): + proc.send_signal(SIGINT) + self.helpers.cancel_tasks_sync(module._tasks) + + @property + def incoming_event_queues(self): + return self.ingress_module.incoming_queues + + @property + def num_queued_events(self): + total = 0 + for q in self.incoming_event_queues: + total += len(q._queue) + return total + + def modules_status(self, _log=False): + finished = True + status = {"modules": {}} + + sorted_modules = [] + for module_name, module in self.modules.items(): + if module_name.startswith("_"): + continue + sorted_modules.append(module) + mod_status = module.status + if mod_status["running"]: + finished = False + status["modules"][module_name] = mod_status + + # sort modules by name + sorted_modules.sort(key=lambda m: m.name) + + status["finished"] = finished + + modules_errored = [m for m, s in status["modules"].items() if s["errored"]] + + max_mem_percent = 90 + mem_status = self.helpers.memory_status() + # abort if we don't have the memory + mem_percent = mem_status.percent + if mem_percent > max_mem_percent: + free_memory = mem_status.available + free_memory_human = self.helpers.bytes_to_human(free_memory) + self.warning(f"System memory is at {mem_percent:.1f}% ({free_memory_human} remaining)") + + if _log: + modules_status = [] + for m, s in status["modules"].items(): + running = s["running"] + incoming = s["events"]["incoming"] + outgoing = s["events"]["outgoing"] + tasks = s["tasks"] + total = sum([incoming, outgoing, tasks]) + if running or total > 0: + modules_status.append((m, running, incoming, outgoing, tasks, total)) + modules_status.sort(key=lambda x: x[-1], reverse=True) + + if modules_status: + modules_status_str = ", ".join([f"{m}({i:,}:{t:,}:{o:,})" for m, r, i, o, t, _ in modules_status]) + self.info(f"{self.name}: Modules running (incoming:processing:outgoing) {modules_status_str}") + else: + self.info(f"{self.name}: No modules running") + event_type_summary = sorted(self.stats.events_emitted_by_type.items(), key=lambda x: x[-1], reverse=True) + if event_type_summary: + self.info( + f'{self.name}: Events produced so far: {", ".join([f"{k}: {v}" for k,v in event_type_summary])}' + ) + else: + self.info(f"{self.name}: No events produced yet") + + if modules_errored: + self.verbose( + f'{self.name}: Modules errored: {len(modules_errored):,} ({", ".join([m for m in modules_errored])})' + ) + + num_queued_events = self.num_queued_events + if num_queued_events: + self.info( + f"{self.name}: {num_queued_events:,} events in queue ({self.stats.speedometer.speed:,} processed in the past minute)" + ) + else: + self.info( + f"{self.name}: No events in queue ({self.stats.speedometer.speed:,} processed in the past minute)" + ) + + if self.log_level <= logging.DEBUG: + # status debugging + scan_active_status = [] + scan_active_status.append(f"scan._finished_init: {self._finished_init}") + scan_active_status.append(f"scan.modules_finished: {self.modules_finished}") + for m in sorted_modules: + running = m.running + scan_active_status.append(f" {m}.finished: {m.finished}") + scan_active_status.append(f" running: {running}") + if running: + scan_active_status.append(f" tasks:") + for task in list(m._task_counter.tasks.values()): + scan_active_status.append(f" - {task}:") + scan_active_status.append(f" incoming_queue_size: {m.num_incoming_events}") + scan_active_status.append(f" outgoing_queue_size: {m.outgoing_event_queue.qsize()}") + for line in scan_active_status: + self.debug(line) + + # log module memory usage + module_memory_usage = [] + for module in sorted_modules: + memory_usage = module.memory_usage + module_memory_usage.append((module.name, memory_usage)) + module_memory_usage.sort(key=lambda x: x[-1], reverse=True) + self.debug(f"MODULE MEMORY USAGE:") + for module_name, usage in module_memory_usage: + self.debug(f" - {module_name}: {self.helpers.bytes_to_human(usage)}") + + status.update({"modules_errored": len(modules_errored)}) + + return status + def stop(self): """Stops the in-progress scan and performs necessary cleanup. @@ -601,13 +709,13 @@ async def finish(self): This method alters the scan's status to "FINISHING" if new activity is detected. """ # if new events were generated since last time we were here - if self.manager._new_activity: - self.manager._new_activity = False + if self._new_activity: + self._new_activity = False self.status = "FINISHING" # Trigger .finished() on every module and start over log.info("Finishing scan") - finished_event = self.make_event("FINISHED", "FINISHED", dummy=True) for module in self.modules.values(): + finished_event = self.make_event(f"FINISHED", "FINISHED", dummy=True, tags={module.name}) await module.queue_event(finished_event) self.verbose("Completed finish()") return True @@ -633,9 +741,6 @@ def _drain_queues(self): while 1: if module.outgoing_event_queue: module.outgoing_event_queue.get_nowait() - with contextlib.suppress(asyncio.queues.QueueEmpty): - while 1: - self.manager.incoming_event_queue.get_nowait() self.debug("Finished draining queues") def _cancel_tasks(self): @@ -666,7 +771,7 @@ def _cancel_tasks(self): tasks += self._manager_worker_loop_tasks self.helpers.cancel_tasks_sync(tasks) # process pool - self.process_pool.shutdown(cancel_futures=True) + self.helpers.process_pool.shutdown(cancel_futures=True) self.debug("Finished cancelling all scan tasks") return tasks @@ -701,47 +806,56 @@ async def _cleanup(self): None """ self.status = "CLEANING_UP" + # clean up dns engine + await self.helpers.dns.shutdown() + # clean up web engine + await self.helpers.web.shutdown() + # clean up modules for mod in self.modules.values(): await mod._cleanup() + # clean up self if not self._cleanedup: self._cleanedup = True with contextlib.suppress(Exception): self.home.rmdir() self.helpers.clean_old_scans() - def in_scope(self, e): - """ - Check whether a hostname, url, IP, etc. is in scope. - Accepts either events or string data. + def in_scope(self, *args, **kwargs): + return self.preset.in_scope(*args, **kwargs) - Checks whitelist and blacklist. - If `e` is an event and its scope distance is zero, it will be considered in-scope. + def whitelisted(self, *args, **kwargs): + return self.preset.whitelisted(*args, **kwargs) - Examples: - Check if a URL is in scope: - >>> scan.in_scope("http://www.evilcorp.com") - True - """ - try: - e = make_event(e, dummy=True) - except ValidationError: - return False - in_scope = e.scope_distance == 0 or self.whitelisted(e) - return in_scope and not self.blacklisted(e) + def blacklisted(self, *args, **kwargs): + return self.preset.blacklisted(*args, **kwargs) - def blacklisted(self, e): - """ - Check whether a hostname, url, IP, etc. is blacklisted. - """ - e = make_event(e, dummy=True) - return e in self.blacklist + @property + def core(self): + return self.preset.core - def whitelisted(self, e): - """ - Check whether a hostname, url, IP, etc. is whitelisted. - """ - e = make_event(e, dummy=True) - return e in self.whitelist + @property + def config(self): + return self.preset.core.config + + @property + def target(self): + return self.preset.target + + @property + def whitelist(self): + return self.preset.whitelist + + @property + def blacklist(self): + return self.preset.blacklist + + @property + def helpers(self): + return self.preset.helpers + + @property + def force_start(self): + return self.preset.force_start @property def word_cloud(self): @@ -767,6 +881,12 @@ def aborting(self): def status(self): return self._status + @property + def omitted_event_types(self): + if self._omitted_event_types is None: + self._omitted_event_types = self.config.get("omit_event_types", []) + return self._omitted_event_types + @status.setter def status(self, status): """ @@ -805,7 +925,7 @@ def root_event(self): "scope_distance": 0, "scan": "SCAN:1188928d942ace8e3befae0bdb9c3caa22705f54", "timestamp": 1694548779.616255, - "source": "SCAN:1188928d942ace8e3befae0bdb9c3caa22705f54", + "parent": "SCAN:1188928d942ace8e3befae0bdb9c3caa22705f54", "tags": [ "distance-0" ], @@ -814,36 +934,49 @@ def root_event(self): } ``` """ - root_event = self.make_event(data=f"{self.name} ({self.id})", event_type="SCAN", dummy=True) + root_event = self.make_event(data=self.json, event_type="SCAN", dummy=True) root_event._id = self.id root_event.scope_distance = 0 - root_event._resolved.set() - root_event.source = root_event - root_event.module = self.helpers._make_dummy_module(name="TARGET", _type="TARGET") + root_event.parent = root_event + root_event.module = self._make_dummy_module(name="TARGET", _type="TARGET") + root_event.discovery_context = f"Scan {self.name} started at {root_event.timestamp}" return root_event - def run_in_executor(self, callback, *args, **kwargs): + @property + def dns_strings(self): """ - Run a synchronous task in the event loop's default thread pool executor - - Examples: - Execute callback: - >>> result = await self.scan.run_in_executor(callback_fn, arg1, arg2) + A list of DNS hostname strings generated from the scan target """ - callback = partial(callback, **kwargs) - return self._loop.run_in_executor(None, callback, *args) + if self._dns_strings is None: + dns_targets = set(t.host for t in self.target if t.host and isinstance(t.host, str)) + dns_whitelist = set(t.host for t in self.whitelist if t.host and isinstance(t.host, str)) + dns_targets.update(dns_whitelist) + dns_targets = sorted(dns_targets, key=len) + dns_targets_set = set() + dns_strings = [] + for t in dns_targets: + if not any(x in dns_targets_set for x in self.helpers.domain_parents(t, include_self=True)): + dns_strings.append(t) + self._dns_strings = dns_strings + return self._dns_strings - def run_in_executor_mp(self, callback, *args, **kwargs): + def _generate_dns_regexes(self, pattern): """ - Same as run_in_executor() except with a process pool executor - Use only in cases where callback is CPU-bound + Generates a list of compiled DNS hostname regexes based on the provided pattern. + This method centralizes the regex compilation to avoid redundancy in the dns_regexes and dns_regexes_yara methods. - Examples: - Execute callback: - >>> result = await self.scan.run_in_executor_mp(callback_fn, arg1, arg2) + Args: + pattern (str): + Returns: + list[re.Pattern]: A list of compiled regex patterns if enabled, otherwise an empty list. """ - callback = partial(callback, **kwargs) - return self._loop.run_in_executor(self.process_pool, callback, *args) + + dns_regexes = [] + for t in self.dns_strings: + regex_pattern = re.compile(f"{pattern}{re.escape(t)})", re.I) + log.debug(f"Generated Regex [{regex_pattern.pattern}] for domain {t}") + dns_regexes.append(regex_pattern) + return dns_regexes @property def dns_regexes(self): @@ -859,27 +992,16 @@ def dns_regexes(self): """ if self._target_dns_regex_disable: return [] - if self._dns_regexes is None: - dns_targets = set(t.host for t in self.target if t.host and isinstance(t.host, str)) - dns_whitelist = set(t.host for t in self.whitelist if t.host and isinstance(t.host, str)) - dns_targets.update(dns_whitelist) - dns_targets = sorted(dns_targets, key=len) - dns_targets_set = set() - dns_regexes = [] - for t in dns_targets: - if not any(x in dns_targets_set for x in self.helpers.domain_parents(t, include_self=True)): - dns_targets_set.add(t) - dns_regexes.append(re.compile(r"((?:(?:[\w-]+)\.)+" + re.escape(t) + ")", re.I)) - self._dns_regexes = dns_regexes - + if not self._dns_regexes: + self._dns_regexes = self._generate_dns_regexes(r"((?:(?:[\w-]+)\.)+") return self._dns_regexes @property - def useragent(self): + def dns_regexes_yara(self): """ - Convenient shortcut to the HTTP user-agent configured for the scan + Returns a list of DNS hostname regexes formatted specifically for compatibility with YARA rules. """ - return self.config.get("user_agent", "BBOT") + return self._generate_dns_regexes(r"(([a-z0-9-]+\.)+") @property def json(self): @@ -891,14 +1013,8 @@ def json(self): v = getattr(self, i, "") if v: j.update({i: v}) - if self.target: - j.update({"targets": [str(e.data) for e in self.target]}) - if self.whitelist: - j.update({"whitelist": [str(e.data) for e in self.whitelist]}) - if self.blacklist: - j.update({"blacklist": [str(e.data) for e in self.blacklist]}) - if self.modules: - j.update({"modules": [str(m) for m in self.modules]}) + j["target"] = self.preset.target.json + j["preset"] = self.preset.to_dict(redact_secrets=True) return j def debug(self, *args, trace=False, **kwargs): @@ -969,7 +1085,7 @@ def log_level(self): """ Return the current log level, e.g. logging.INFO """ - return get_log_level() + return self.core.logger.log_level @property def _log_handlers(self): @@ -978,60 +1094,46 @@ def _log_handlers(self): main_handler = logging.handlers.TimedRotatingFileHandler( str(self.home / "scan.log"), when="d", interval=1, backupCount=14 ) - main_handler.addFilter( - lambda x: x.levelno not in (logging.STDOUT, logging.TRACE) and x.levelno >= logging.VERBOSE - ) + main_handler.addFilter(lambda x: x.levelno != logging.TRACE and x.levelno >= logging.VERBOSE) debug_handler = logging.handlers.TimedRotatingFileHandler( str(self.home / "debug.log"), when="d", interval=1, backupCount=14 ) - debug_handler.addFilter(lambda x: x.levelno != logging.STDOUT and x.levelno >= logging.DEBUG) + debug_handler.addFilter(lambda x: x.levelno >= logging.DEBUG) self.__log_handlers = [main_handler, debug_handler] return self.__log_handlers def _start_log_handlers(self): # add log handlers for handler in self._log_handlers: - add_log_handler(handler) + self.core.logger.add_log_handler(handler) # temporarily disable main ones for handler_name in ("file_main", "file_debug"): - handler = get_log_handlers().get(handler_name, None) + handler = self.core.logger.log_handlers.get(handler_name, None) if handler is not None and handler not in self._log_handler_backup: self._log_handler_backup.append(handler) - remove_log_handler(handler) + self.core.logger.remove_log_handler(handler) def _stop_log_handlers(self): # remove log handlers for handler in self._log_handlers: - remove_log_handler(handler) + self.core.logger.remove_log_handler(handler) # restore main ones for handler in self._log_handler_backup: - add_log_handler(handler) - - def _internal_modules(self): - for modname in module_loader.preloaded(type="internal"): - if self.config.get(modname, True): - yield modname + self.core.logger.add_log_handler(handler) def _fail_setup(self, msg): msg = str(msg) - if not self.force_start: - msg += " (--force to run module anyway)" if self.force_start: self.error(msg) else: + msg += " (--force to run module anyway)" raise ScanError(msg) - @property - def _loop(self): - if self.__loop is None: - self.__loop = asyncio.get_event_loop() - return self.__loop - def _load_modules(self, modules): modules = [str(m) for m in modules] loaded_modules = {} failed = set() - for module_name, module_class in module_loader.load_modules(modules).items(): + for module_name, module_class in self.preset.module_loader.load_modules(modules).items(): if module_class: try: loaded_modules[module_name] = module_class(self) @@ -1048,10 +1150,10 @@ async def _status_ticker(self, interval=15): async with self._acatch(): while 1: await asyncio.sleep(interval) - self.manager.modules_status(_log=True) + self.modules_status(_log=True) @contextlib.asynccontextmanager - async def _acatch(self, context="scan", finally_callback=None): + async def _acatch(self, context="scan", finally_callback=None, unhandled_is_critical=False): """ Async version of catch() @@ -1061,14 +1163,13 @@ async def _acatch(self, context="scan", finally_callback=None): try: yield except BaseException as e: - self._handle_exception(e, context=context) + self._handle_exception(e, context=context, unhandled_is_critical=unhandled_is_critical) - def _handle_exception(self, e, context="scan", finally_callback=None): + def _handle_exception(self, e, context="scan", finally_callback=None, unhandled_is_critical=False): if callable(context): context = f"{context.__qualname__}()" filename, lineno, funcname = self.helpers.get_traceback_details(e) - exception_chain = self.helpers.get_exception_chain(e) - if any(isinstance(exc, KeyboardInterrupt) for exc in exception_chain): + if self.helpers.in_exception_chain(e, (KeyboardInterrupt,)): log.debug(f"Interrupted") self.stop() elif isinstance(e, BrokenPipeError): @@ -1076,7 +1177,44 @@ def _handle_exception(self, e, context="scan", finally_callback=None): elif isinstance(e, asyncio.CancelledError): raise elif isinstance(e, Exception): - log.error(f"Error in {context}: {filename}:{lineno}:{funcname}(): {e}") - log.trace(traceback.format_exc()) + if unhandled_is_critical: + log.critical(f"Error in {context}: {filename}:{lineno}:{funcname}(): {e}") + log.critical(traceback.format_exc()) + else: + log.error(f"Error in {context}: {filename}:{lineno}:{funcname}(): {e}") + log.trace(traceback.format_exc()) if callable(finally_callback): finally_callback(e) + + def _make_dummy_module(self, name, _type="scan"): + """ + Construct a dummy module, for attachment to events + """ + try: + return self.dummy_modules[name] + except KeyError: + dummy = DummyModule(scan=self, name=name, _type=_type) + self.dummy_modules[name] = dummy + return dummy + + def _make_dummy_module_dns(self, name): + try: + dummy_module = self.dummy_modules[name] + except KeyError: + dummy_module = self._make_dummy_module(name=name, _type="DNS") + dummy_module.suppress_dupes = False + dummy_module._priority = 4 + self.dummy_modules[name] = dummy_module + return dummy_module + + +from bbot.modules.base import BaseModule + + +class DummyModule(BaseModule): + _priority = 4 + + def __init__(self, *args, **kwargs): + self._name = kwargs.pop("name") + self._type = kwargs.pop("_type") + super().__init__(*args, **kwargs) diff --git a/bbot/scanner/stats.py b/bbot/scanner/stats.py index 0c0a4d287..6ae86c044 100644 --- a/bbot/scanner/stats.py +++ b/bbot/scanner/stats.py @@ -1,4 +1,6 @@ +import time import logging +from collections import deque log = logging.getLogger("bbot.scanner.stats") @@ -10,11 +12,36 @@ def _increment(d, k): d[k] = 1 +class SpeedCounter: + """ + A simple class for keeping a rolling tally of the number of events inside a specific time window + """ + + def __init__(self, window=60): + self.timestamps = deque() + self.window = window + + def tick(self): + current_time = time.time() + self.timestamps.append(current_time) + self.remove_old_timestamps(current_time) + + def remove_old_timestamps(self, current_time): + while self.timestamps and current_time - self.timestamps[0] > self.window: + self.timestamps.popleft() + + @property + def speed(self): + self.remove_old_timestamps(time.time()) + return len(self.timestamps) + + class ScanStats: def __init__(self, scan): self.scan = scan self.module_stats = {} self.events_emitted_by_type = {} + self.speedometer = SpeedCounter(60) def event_produced(self, event): _increment(self.events_emitted_by_type, event.type) @@ -23,6 +50,10 @@ def event_produced(self, event): module_stat.increment_produced(event) def event_consumed(self, event, module): + self.speedometer.tick() + # skip ingress/egress modules, etc. + if module.name.startswith("_"): + return module_stat = self.get(module) if module_stat is not None: module_stat.increment_consumed(event) diff --git a/bbot/scanner/target.py b/bbot/scanner/target.py index f12189545..8b88882ce 100644 --- a/bbot/scanner/target.py +++ b/bbot/scanner/target.py @@ -1,26 +1,221 @@ import re +import copy import logging import ipaddress +import traceback +from hashlib import sha1 from contextlib import suppress +from radixtarget import RadixTarget -from bbot.core.errors import * +from bbot.errors import * from bbot.modules.base import BaseModule +from bbot.core.helpers.misc import make_ip_type from bbot.core.event import make_event, is_event log = logging.getLogger("bbot.core.target") +class BBOTTarget: + """ + A convenient abstraction of a scan target that includes whitelisting and blacklisting + + Provides high-level functions like in_scope(), which includes both whitelist and blacklist checks. + """ + + def __init__(self, *targets, whitelist=None, blacklist=None, strict_scope=False, scan=None): + self.strict_scope = strict_scope + self.scan = scan + if len(targets) > 0: + log.verbose(f"Creating events from {len(targets):,} targets") + self.seeds = Target(*targets, strict_scope=self.strict_scope, scan=scan) + if whitelist is None: + whitelist = set([e.host for e in self.seeds if e.host]) + else: + log.verbose(f"Creating events from {len(whitelist):,} whitelist entries") + self.whitelist = Target(*whitelist, strict_scope=self.strict_scope, scan=scan, acl_mode=True) + if blacklist is None: + blacklist = [] + if blacklist: + log.verbose(f"Creating events from {len(blacklist):,} blacklist entries") + self.blacklist = Target(*blacklist, scan=scan, acl_mode=True) + self._hash = None + + def add(self, *args, **kwargs): + self.seeds.add(*args, **kwargs) + self._hash = None + + def get(self, host): + return self.seeds.get(host) + + def get_host(self, host): + return self.seeds.get(host) + + def __iter__(self): + return iter(self.seeds) + + def __len__(self): + return len(self.seeds) + + def __contains__(self, other): + if isinstance(other, self.__class__): + other = other.seeds + return other in self.seeds + + def __bool__(self): + return bool(self.seeds) + + def __eq__(self, other): + return self.hash == other.hash + + @property + def hash(self): + """ + A sha1 hash representing a BBOT target and all three of its components (seeds, whitelist, blacklist) + + This can be used to compare targets. + + Examples: + >>> target1 = BBOTTarget("evilcorp.com", blacklist=["prod.evilcorp.com"], whitelist=["test.evilcorp.com"]) + >>> target2 = BBOTTarget("evilcorp.com", blacklist=["prod.evilcorp.com"], whitelist=["test.evilcorp.com"]) + >>> target3 = BBOTTarget("evilcorp.com", blacklist=["prod.evilcorp.com"]) + >>> target1 == target2 + True + >>> target1 == target3 + False + """ + if self._hash is None: + # Create a new SHA-1 hash object + sha1_hash = sha1() + # Update the SHA-1 object with the hash values of each object + for target_hash in [t.hash for t in (self.seeds, self.whitelist, self.blacklist)]: + # Convert the hash value to bytes and update the SHA-1 object + sha1_hash.update(target_hash) + self._hash = sha1_hash.digest() + return self._hash + + @property + def scope_hash(self): + """ + A sha1 hash representing only the whitelist and blacklist + + This is used to record the scope of a scan. + """ + # Create a new SHA-1 hash object + sha1_hash = sha1() + # Update the SHA-1 object with the hash values of each object + for target_hash in [t.hash for t in (self.whitelist, self.blacklist)]: + # Convert the hash value to bytes and update the SHA-1 object + sha1_hash.update(target_hash) + return sha1_hash.digest() + + @property + def json(self): + return { + "seeds": sorted([e.data for e in self.seeds]), + "whitelist": sorted([e.data for e in self.whitelist]), + "blacklist": sorted([e.data for e in self.blacklist]), + "strict_scope": self.strict_scope, + "hash": self.hash.hex(), + "seed_hash": self.seeds.hash.hex(), + "whitelist_hash": self.whitelist.hash.hex(), + "blacklist_hash": self.blacklist.hash.hex(), + "scope_hash": self.scope_hash.hex(), + } + + def copy(self): + self_copy = copy.copy(self) + self_copy.seeds = self.seeds.copy() + self_copy.whitelist = self.whitelist.copy() + self_copy.blacklist = self.blacklist.copy() + return self_copy + + @property + def events(self): + return self.seeds.events + + 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. + + Examples: + Check if a URL is in scope: + >>> preset.in_scope("http://www.evilcorp.com") + True + """ + try: + e = make_event(host, dummy=True) + except ValidationError: + return False + in_scope = e.scope_distance == 0 or self.whitelisted(e) + return in_scope and not self.blacklisted(e) + + def blacklisted(self, host): + """ + Check whether a hostname, url, IP, etc. is blacklisted. + + 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 blacklist + + Examples: + Check if a URL's host is blacklisted: + >>> preset.blacklisted("http://www.evilcorp.com") + True + """ + e = make_event(host, dummy=True) + return e in self.blacklist + + def whitelisted(self, host): + """ + Check whether a hostname, url, IP, etc. is whitelisted. + + 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 + + Examples: + Check if a URL's host is whitelisted: + >>> preset.whitelisted("http://www.evilcorp.com") + True + """ + e = make_event(host, dummy=True) + whitelist = self.whitelist + if whitelist is None: + whitelist = self.seeds + return e in whitelist + + @property + def radix_only(self): + """ + A slimmer, serializable version of the target designed for simple scope checks + + This version doesn't have the events, only their hosts. + """ + return self.__class__( + *[e.host for e in self.seeds if e.host], + whitelist=None if self.whitelist is None else [e for e in self.whitelist], + blacklist=[e for e in self.blacklist], + strict_scope=self.strict_scope, + ) + + class Target: """ A class representing a target. Can contain an unlimited number of hosts, IP or IP ranges, URLs, etc. Attributes: - make_in_scope (bool): Specifies whether to mark contained events as in-scope. - scan (Scan): Reference to the Scan object that instantiated the Target. - _events (dict): Dictionary mapping hosts to events related to the target. strict_scope (bool): Flag indicating whether to consider child domains in-scope. If set to True, only the exact hosts specified and not their children are considered part of the target. + _radix (RadixTree): Radix tree for quick IP/DNS lookups. + _events (set): Flat set of contained events. + Examples: Basic usage >>> target = Target(scan, "evilcorp.com", "1.2.3.0/24") @@ -63,21 +258,15 @@ class Target: - If you do not want to include child subdomains, use `strict_scope=True` """ - def __init__(self, scan, *targets, strict_scope=False, make_in_scope=False): + def __init__(self, *targets, strict_scope=False, scan=None, acl_mode=False): """ Initialize a Target object. Args: - scan (Scan): Reference to the Scan object that instantiated the Target. *targets: One or more targets (e.g., domain names, IP ranges) to be included in this Target. - strict_scope (bool, optional): Flag to control whether only the exact hosts are considered in-scope. - Defaults to False. - make_in_scope (bool, optional): Flag to control whether contained events are marked as in-scope. - Defaults to False. - - Attributes: - scan (Scan): Reference to the Scan object. - strict_scope (bool): Flag to control in-scope conditions. If True, only exact hosts are considered. + strict_scope (bool): Whether to consider subdomains of target domains in-scope + scan (Scan): Reference to the Scan object that instantiated the Target. + acl_mode (bool): Stricter deduplication for more efficient checks Notes: - If you are instantiating a target from within a BBOT module, use `self.helpers.make_target()` instead. (this removes the need to pass in a scan object.) @@ -86,22 +275,20 @@ def __init__(self, scan, *targets, strict_scope=False, make_in_scope=False): """ self.scan = scan self.strict_scope = strict_scope - self.make_in_scope = make_in_scope + self.acl_mode = acl_mode self.special_event_types = { "ORG_STUB": re.compile(r"^ORG:(.*)", re.IGNORECASE), "ASN": re.compile(r"^ASN:(.*)", re.IGNORECASE), } + self._events = set() + self._radix = RadixTarget() - self._dummy_module = TargetDummyModule(scan) - self._events = dict() - if len(targets) > 0: - log.verbose(f"Creating events from {len(targets):,} targets") - for t in targets: - self.add_target(t) + for target_event in self._make_events(targets): + self._add_event(target_event) self._hash = None - def add_target(self, t, event_type=None): + def add(self, t, event_type=None): """ Add a target or merge events from another Target object into this Target. @@ -112,54 +299,37 @@ def add_target(self, t, event_type=None): _events (dict): The dictionary is updated to include the new target's events. Examples: - >>> target.add_target('example.com') + >>> target.add('example.com') Notes: - If `t` is of the same class as this Target, all its events are merged. - If `t` is an event, it is directly added to `_events`. - - If `make_in_scope` is True, the scope distance of the event is set to 0. """ - if type(t) == self.__class__: - for k, v in t._events.items(): - try: - self._events[k].update(v) - except KeyError: - self._events[k] = set(t._events[k]) - else: - if is_event(t): - event = t + if not isinstance(t, (list, tuple, set)): + t = [t] + for single_target in t: + if isinstance(single_target, self.__class__): + for event in single_target.events: + self._add_event(event) else: - for eventtype, regex in self.special_event_types.items(): - match = regex.match(t) - if match: - t = match.groups()[0] - event_type = eventtype - break - try: - event = self.scan.make_event( - t, - event_type=event_type, - source=self.scan.root_event, - module=self._dummy_module, - tags=["target"], - ) - except ValidationError as e: - # allow commented lines - if not str(t).startswith("#"): - raise ValidationError(f'Could not add target "{t}": {e}') - if self.make_in_scope and event.host: - event.scope_distance = 0 - try: - self._events[event.host].add(event) - except KeyError: - self._events[event.host] = { - event, - } + if is_event(single_target): + event = single_target + else: + try: + event = make_event( + single_target, event_type=event_type, dummy=True, tags=["target"], scan=self.scan + ) + except ValidationError as e: + # allow commented lines + if not str(t).startswith("#"): + log.trace(traceback.format_exc()) + raise ValidationError(f'Could not add target "{t}": {e}') + self._add_event(event) @property def events(self): """ - A generator property that yields all events in the target. + Returns all events in the target. Yields: Event object: One of the Event objects stored in the `_events` dictionary. @@ -171,17 +341,19 @@ def events(self): Notes: - This property is read-only. - - Iterating over this property gives you one event at a time from the `_events` dictionary. """ - for _events in self._events.values(): - yield from _events + return self._events + + @property + def hosts(self): + return [e.host for e in self.events] def copy(self): """ - Creates and returns a copy of the Target object, including a shallow copy of the `_events` attribute. + Creates and returns a copy of the Target object, including a shallow copy of the `_events` and `_radix` attributes. Returns: - Target: A new Target object with the same `scan` and `strict_scope` attributes as the original. + Target: A new Target object with the sameattributes as the original. A shallow copy of the `_events` dictionary is made. Examples: @@ -199,16 +371,18 @@ def copy(self): Notes: - The `scan` object reference is kept intact in the copied Target object. """ - self_copy = self.__class__(self.scan, strict_scope=self.strict_scope) - self_copy._events = dict(self._events) + self_copy = self.__class__() + self_copy._events = set(self._events) + self_copy._radix = copy.copy(self._radix) return self_copy - def get(self, host): + def get(self, host, single=True): """ - Gets the event associated with the specified host from the target's `_events` dictionary. + Gets the event associated with the specified host from the target's radix tree. Args: host (Event, Target, or str): The hostname, IP, URL, or event to look for. + single (bool): Whether to return a single event. If False, return all events matching the host Returns: Event or None: Returns the Event object associated with the given host if it exists, otherwise returns None. @@ -224,22 +398,78 @@ def get(self, host): - The method returns the first event that matches the given host. - If `strict_scope` is False, it will also consider parent domains and IP ranges. """ - try: - other = make_event(host, dummy=True) + event = make_event(host, dummy=True) except ValidationError: return - if other.host: - with suppress(KeyError, StopIteration): - return next(iter(self._events[other.host])) - if self.scan.helpers.is_ip_type(other.host): - for n in self.scan.helpers.ip_network_parents(other.host, include_self=True): - with suppress(KeyError, StopIteration): - return next(iter(self._events[n])) - elif not self.strict_scope: - for h in self.scan.helpers.domain_parents(other.host): - with suppress(KeyError, StopIteration): - return next(iter(self._events[h])) + if event.host: + return self.get_host(event.host, single=single) + + def get_host(self, host, single=True): + """ + A more efficient version of .get() that only accepts hostnames and IP addresses + """ + host = make_ip_type(host) + with suppress(KeyError, StopIteration): + result = self._radix.search(host) + if result is not None: + ret = set() + for event in result: + # if the result is a dns name and strict scope is enabled + if isinstance(event.host, str) and self.strict_scope: + # if the result doesn't exactly equal the host, abort + if event.host != host: + return + if single: + return event + else: + ret.add(event) + if ret and not single: + return ret + + def _sort_events(self, events): + return sorted(events, key=lambda x: x._host_size) + + def _make_events(self, targets): + events = [] + for target in targets: + event_type = None + for eventtype, regex in self.special_event_types.items(): + if isinstance(target, str): + match = regex.match(target) + if match: + target = match.groups()[0] + event_type = eventtype + break + events.append(make_event(target, event_type=event_type, dummy=True, scan=self.scan)) + return self._sort_events(events) + + def _add_event(self, event): + skip = False + if event.host: + radix_data = self._radix.search(event.host) + if self.acl_mode: + # skip if the hostname/IP/subnet (or its parent) has already been added + if radix_data is not None and not self.strict_scope: + skip = True + else: + event_type = "IP_RANGE" if event.type == "IP_RANGE" else "DNS_NAME" + event = make_event(event.host, event_type=event_type, dummy=True, scan=self.scan) + if not skip: + # if strict scope is enabled and it's not an exact host match, we add a whole new entry + if radix_data is None or (self.strict_scope and event.host not in radix_data): + radix_data = {event} + self._radix.insert(event.host, radix_data) + # otherwise, we add the event to the set + else: + radix_data.add(event) + # clear hash + self._hash = None + elif self.acl_mode and not self.strict_scope: + # skip if we're in ACL mode and there's no host + skip = True + if not skip: + self._events.add(event) def _contains(self, other): if self.get(other) is not None: @@ -254,7 +484,7 @@ def __iter__(self): def __contains__(self, other): # if "other" is a Target - if type(other) == self.__class__: + if isinstance(other, self.__class__): contained_in_self = [self._contains(e) for e in other.events] return all(contained_in_self) else: @@ -264,12 +494,20 @@ def __bool__(self): return bool(self._events) def __eq__(self, other): - return hash(self) == hash(other) + return self.hash == other.hash - def __hash__(self): + @property + def hash(self): if self._hash is None: - events = tuple(sorted(list(self.events), key=lambda e: hash(e))) - self._hash = hash(events) + # Create a new SHA-1 hash object + sha1_hash = sha1() + # Update the SHA-1 object with the hash values of each object + for event_type, event_hash in sorted([(e.type.encode(), e.data_hash) for e in self.events]): + sha1_hash.update(event_type) + sha1_hash.update(event_hash) + if self.strict_scope: + sha1_hash.update(b"\x00") + self._hash = sha1_hash.digest() return self._hash def __len__(self): @@ -289,11 +527,11 @@ def __len__(self): - For other types of hosts, each unique event is counted as one. """ num_hosts = 0 - for host, _events in self._events.items(): - if type(host) in (ipaddress.IPv4Network, ipaddress.IPv6Network): - num_hosts += host.num_addresses + for event in self._events: + if isinstance(event.host, (ipaddress.IPv4Network, ipaddress.IPv6Network)): + num_hosts += event.host.num_addresses else: - num_hosts += len(_events) + num_hosts += 1 return num_hosts diff --git a/bbot/scripts/docs.py b/bbot/scripts/docs.py index 8e6d045f3..0bfe5409f 100755 --- a/bbot/scripts/docs.py +++ b/bbot/scripts/docs.py @@ -2,11 +2,15 @@ import os import re +import json import yaml from pathlib import Path -from bbot.modules import module_loader -from bbot.core.configurator.args import parser, scan_examples +from bbot import Preset +from bbot.core.modules import MODULE_LOADER + + +DEFAULT_PRESET = Preset() os.environ["BBOT_TABLE_FORMAT"] = "github" @@ -18,6 +22,82 @@ bbot_code_dir = Path(__file__).parent.parent.parent +def gen_chord_data(): + # This function generates the dataset for the chord graph in the documentation + # showing relationships between BBOT modules and their consumed/produced event types + preloaded_mods = sorted(MODULE_LOADER.preloaded().items(), key=lambda x: x[0]) + + entity_lookup_table = {} + rels = [] + entities = {} + entity_counter = 1 + + def add_entity(entity, parent_id): + if entity not in entity_lookup_table: + nonlocal entity_counter + e_id = entity_counter + entity_counter += 1 + entity_lookup_table[entity] = e_id + entity_lookup_table[e_id] = entity + entities[e_id] = {"id": e_id, "name": entity, "parent": parent_id, "consumes": [], "produces": []} + return entity_lookup_table[entity] + + # create entities for all the modules and event types + for module, preloaded in preloaded_mods: + watched = [e for e in preloaded["watched_events"] if e != "*"] + produced = [e for e in preloaded["produced_events"] if e != "*"] + if watched or produced: + m_id = add_entity(module, 99999999) + for event_type in watched: + e_id = add_entity(event_type, 88888888) + entities[m_id]["consumes"].append(e_id) + entities[e_id]["consumes"].append(m_id) + for event_type in produced: + e_id = add_entity(event_type, 88888888) + entities[m_id]["produces"].append(e_id) + entities[e_id]["produces"].append(m_id) + + def add_rel(incoming, outgoing, t): + if incoming == "*" or outgoing == "*": + return + i_id = entity_lookup_table[incoming] + o_id = entity_lookup_table[outgoing] + rels.append({"source": i_id, "target": o_id, "type": t}) + + # create all the module <--> event type relationships + for module, preloaded in preloaded_mods: + for event_type in preloaded["watched_events"]: + add_rel(module, event_type, "consumes") + for event_type in preloaded["produced_events"]: + add_rel(event_type, module, "produces") + + # write them to JSON files + data_dir = Path(__file__).parent.parent.parent / "docs" / "data" / "chord_graph" + data_dir.mkdir(parents=True, exist_ok=True) + entity_file = data_dir / "entities.json" + rels_file = data_dir / "rels.json" + + entities = [ + {"id": 77777777, "name": "root"}, + {"id": 99999999, "name": "module", "parent": 77777777}, + {"id": 88888888, "name": "event_type", "parent": 77777777}, + ] + sorted(entities.values(), key=lambda x: x["name"]) + + with open(entity_file, "w") as f: + json.dump(entities, f, indent=4) + + with open(rels_file, "w") as f: + json.dump(rels, f, indent=4) + + +def homedir_collapseuser(f): + f = Path(f) + home_dir = Path.home() + if f.is_relative_to(home_dir): + return Path("~") / f.relative_to(home_dir) + return f + + def enclose_tags(text): # Use re.sub() to replace matched words with the same words enclosed in backticks result = blacklist_re.sub(r"|`\1`|", text) @@ -63,12 +143,12 @@ def update_individual_module_options(): content = f.read() for match in regex.finditer(content): module_name = match.groups()[0].lower() - bbot_module_options_table = module_loader.modules_options_table(modules=[module_name]) + bbot_module_options_table = DEFAULT_PRESET.module_loader.modules_options_table(modules=[module_name]) find_replace_file(file, f"BBOT MODULE OPTIONS {module_name.upper()}", bbot_module_options_table) # Example commands bbot_example_commands = [] - for title, description, command in scan_examples: + for title, description, command in DEFAULT_PRESET.args.scan_examples: example = "" example += f"**{title}:**\n\n" # example += f"{description}\n" @@ -79,37 +159,92 @@ def update_individual_module_options(): update_md_files("BBOT EXAMPLE COMMANDS", bbot_example_commands) # Help output - bbot_help_output = parser.format_help().replace("docs.py", "bbot") + bbot_help_output = DEFAULT_PRESET.args.parser.format_help().replace("docs.py", "bbot") bbot_help_output = f"```text\n{bbot_help_output}\n```" assert len(bbot_help_output.splitlines()) > 50 update_md_files("BBOT HELP OUTPUT", bbot_help_output) # BBOT events - bbot_event_table = module_loader.events_table() + bbot_event_table = DEFAULT_PRESET.module_loader.events_table() assert len(bbot_event_table.splitlines()) > 10 update_md_files("BBOT EVENTS", bbot_event_table) # BBOT modules - bbot_module_table = module_loader.modules_table() + bbot_module_table = DEFAULT_PRESET.module_loader.modules_table(include_author=True, include_created_date=True) assert len(bbot_module_table.splitlines()) > 50 update_md_files("BBOT MODULES", bbot_module_table) # BBOT output modules - bbot_output_module_table = module_loader.modules_table(mod_type="output") + bbot_output_module_table = DEFAULT_PRESET.module_loader.modules_table( + mod_type="output", include_author=True, include_created_date=True + ) assert len(bbot_output_module_table.splitlines()) > 10 update_md_files("BBOT OUTPUT MODULES", bbot_output_module_table) # BBOT module options - bbot_module_options_table = module_loader.modules_options_table() + bbot_module_options_table = DEFAULT_PRESET.module_loader.modules_options_table() assert len(bbot_module_options_table.splitlines()) > 100 update_md_files("BBOT MODULE OPTIONS", bbot_module_options_table) update_individual_module_options() # BBOT module flags - bbot_module_flags_table = module_loader.flags_table() + bbot_module_flags_table = DEFAULT_PRESET.module_loader.flags_table() assert len(bbot_module_flags_table.splitlines()) > 10 update_md_files("BBOT MODULE FLAGS", bbot_module_flags_table) + # BBOT presets + bbot_presets_table = DEFAULT_PRESET.presets_table(include_modules=True) + assert len(bbot_presets_table.splitlines()) > 5 + update_md_files("BBOT PRESETS", bbot_presets_table) + + # BBOT presets + for yaml_file, (loaded_preset, category, preset_path, original_filename) in DEFAULT_PRESET.all_presets.items(): + preset_yaml = f""" +```yaml title={yaml_file.name} +{loaded_preset._yaml_str} +``` +""" + preset_yaml_expandable = f""" +
+{yaml_file.name} + +```yaml +{loaded_preset._yaml_str} +``` + +
+""" + update_md_files(f"BBOT {loaded_preset.name.upper()} PRESET", preset_yaml) + update_md_files(f"BBOT {loaded_preset.name.upper()} PRESET EXPANDABLE", preset_yaml_expandable) + + content = [] + for yaml_file, (loaded_preset, category, preset_path, original_filename) in DEFAULT_PRESET.all_presets.items(): + yaml_str = loaded_preset._yaml_str + indent = " " * 4 + yaml_str = f"\n{indent}".join(yaml_str.splitlines()) + filename = homedir_collapseuser(yaml_file) + + num_modules = len(loaded_preset.scan_modules) + modules = ", ".join(sorted([f"`{m}`" for m in loaded_preset.scan_modules])) + category = f"Category: {category}" if category else "" + + content.append( + f"""## **{loaded_preset.name}** + +{loaded_preset.description} + +??? note "`{filename.name}`" + ```yaml title="{filename}" + {yaml_str} + ``` + +{category} + +Modules: [{num_modules:,}]("{modules}")""" + ) + assert len(content) > 5 + update_md_files("BBOT PRESET YAML", "\n\n".join(content)) + # Default config default_config_file = bbot_code_dir / "bbot" / "defaults.yml" with open(default_config_file) as f: @@ -154,5 +289,8 @@ def update_toc(section, level=0): # assert len(bbot_docs_toc.splitlines()) == 2 update_md_files("BBOT DOCS TOC", bbot_docs_toc) + # generate data for chord graph + gen_chord_data() + update_docs() diff --git a/bbot/test/bbot_fixtures.py b/bbot/test/bbot_fixtures.py index 85af696cd..44cc7a3ba 100644 --- a/bbot/test/bbot_fixtures.py +++ b/bbot/test/bbot_fixtures.py @@ -1,17 +1,46 @@ -import os -import dns +import os # noqa import sys import pytest +import shutil # noqa import asyncio # noqa import logging import subprocess import tldextract import pytest_httpserver from pathlib import Path -from omegaconf import OmegaConf +from omegaconf import OmegaConf # noqa from werkzeug.wrappers import Request +from bbot.errors import * # noqa: F401 +from bbot.core import CORE +from bbot.scanner import Preset +from bbot.core.helpers.misc import mkdir + + +log = logging.getLogger(f"bbot.test.fixtures") + + +bbot_test_dir = Path("/tmp/.bbot_test") +mkdir(bbot_test_dir) + + +DEFAULT_PRESET = Preset() + +available_modules = list(DEFAULT_PRESET.module_loader.configs(type="scan")) +available_output_modules = list(DEFAULT_PRESET.module_loader.configs(type="output")) +available_internal_modules = list(DEFAULT_PRESET.module_loader.configs(type="internal")) + + +@pytest.fixture +def clean_default_config(monkeypatch): + clean_config = OmegaConf.merge( + CORE.files_config.get_default_config(), {"modules": DEFAULT_PRESET.module_loader.configs()} + ) + with monkeypatch.context() as m: + m.setattr("bbot.core.core.DEFAULT_CONFIG", clean_config) + yield + class SubstringRequestMatcher(pytest_httpserver.httpserver.RequestMatcher): def match_data(self, request: Request) -> bool: @@ -22,28 +51,12 @@ def match_data(self, request: Request) -> bool: pytest_httpserver.httpserver.RequestMatcher = SubstringRequestMatcher - -test_config = OmegaConf.load(Path(__file__).parent / "test.conf") -if test_config.get("debug", False): - os.environ["BBOT_DEBUG"] = "True" - -from .bbot_fixtures import * # noqa: F401 -import bbot.core.logger # noqa: F401 -from bbot.core.errors import * # noqa: F401 - # silence pytest_httpserver log = logging.getLogger("werkzeug") log.setLevel(logging.CRITICAL) -# silence stdout -root_logger = logging.getLogger() -for h in root_logger.handlers: - h.addFilter(lambda x: x.levelname not in ("STDOUT", "TRACE")) - tldextract.extract("www.evilcorp.com") -log = logging.getLogger(f"bbot.test.fixtures") - @pytest.fixture def bbot_scanner(): @@ -53,16 +66,10 @@ def bbot_scanner(): @pytest.fixture -def scan(monkeypatch, bbot_config): +def scan(monkeypatch): from bbot.scanner import Scanner - bbot_scan = Scanner("127.0.0.1", modules=["ipneighbor"], config=bbot_config) - - fallback_nameservers_file = bbot_scan.helpers.bbot_home / "fallback_nameservers.txt" - with open(fallback_nameservers_file, "w") as f: - f.write("8.8.8.8\n") - monkeypatch.setattr(bbot_scan.helpers.dns, "fallback_nameservers_file", fallback_nameservers_file) - + bbot_scan = Scanner("127.0.0.1", modules=["ipneighbor"]) return bbot_scan @@ -121,47 +128,47 @@ def helpers(scan): @pytest.fixture def events(scan): class bbot_events: - localhost = scan.make_event("127.0.0.1", source=scan.root_event) - ipv4 = scan.make_event("8.8.8.8", source=scan.root_event) - netv4 = scan.make_event("8.8.8.8/30", source=scan.root_event) - ipv6 = scan.make_event("2001:4860:4860::8888", source=scan.root_event) - netv6 = scan.make_event("2001:4860:4860::8888/126", source=scan.root_event) - domain = scan.make_event("publicAPIs.org", source=scan.root_event) - subdomain = scan.make_event("api.publicAPIs.org", source=scan.root_event) - email = scan.make_event("bob@evilcorp.co.uk", "EMAIL_ADDRESS", source=scan.root_event) - open_port = scan.make_event("api.publicAPIs.org:443", source=scan.root_event) + 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) protocol = scan.make_event( - {"host": "api.publicAPIs.org", "port": 443, "protocol": "HTTP"}, "PROTOCOL", source=scan.root_event + {"host": "api.publicAPIs.org", "port": 443, "protocol": "HTTP"}, "PROTOCOL", parent=scan.root_event ) - ipv4_open_port = scan.make_event("8.8.8.8:443", source=scan.root_event) - ipv6_open_port = scan.make_event("[2001:4860:4860::8888]:443", "OPEN_TCP_PORT", source=scan.root_event) - url_unverified = scan.make_event("https://api.publicAPIs.org:443/hellofriend", source=scan.root_event) - ipv4_url_unverified = scan.make_event("https://8.8.8.8:443/hellofriend", source=scan.root_event) - ipv6_url_unverified = scan.make_event("https://[2001:4860:4860::8888]:443/hellofriend", source=scan.root_event) + 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"], source=scan.root_event + "https://api.publicAPIs.org:443/hellofriend", "URL", tags=["status-200"], parent=scan.root_event ) ipv4_url = scan.make_event( - "https://8.8.8.8:443/hellofriend", "URL", tags=["status-200"], source=scan.root_event + "https://8.8.8.8:443/hellofriend", "URL", tags=["status-200"], parent=scan.root_event ) ipv6_url = scan.make_event( - "https://[2001:4860:4860::8888]:443/hellofriend", "URL", tags=["status-200"], source=scan.root_event + "https://[2001:4860:4860::8888]:443/hellofriend", "URL", tags=["status-200"], parent=scan.root_event ) - url_hint = scan.make_event("https://api.publicAPIs.org:443/hello.ash", "URL_HINT", source=url) + 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", - source=scan.root_event, + parent=scan.root_event, ) - finding = scan.make_event({"host": "evilcorp.com", "description": "asdf"}, "FINDING", source=scan.root_event) - vhost = scan.make_event({"host": "evilcorp.com", "vhost": "www.evilcorp.com"}, "VHOST", source=scan.root_event) - http_response = scan.make_event(httpx_response, "HTTP_RESPONSE", source=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) + 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"}, "STORAGE_BUCKET", - source=scan.root_event, + parent=scan.root_event, ) - emoji = scan.make_event("💩", "WHERE_IS_YOUR_GOD_NOW", source=scan.root_event) + emoji = scan.make_event("💩", "WHERE_IS_YOUR_GOD_NOW", parent=scan.root_event) bbot_events.all = [ # noqa: F841 bbot_events.localhost, @@ -197,104 +204,9 @@ class bbot_events: return bbot_events -@pytest.fixture -def agent(monkeypatch, bbot_config): - from bbot import agent - - test_agent = agent.Agent(bbot_config) - test_agent.setup() - return test_agent - - -# bbot config -from bbot import config as default_config - -test_config = OmegaConf.load(Path(__file__).parent / "test.conf") -test_config = OmegaConf.merge(default_config, test_config) - -if test_config.get("debug", False): - logging.getLogger("bbot").setLevel(logging.DEBUG) - - -@pytest.fixture -def bbot_config(): - return test_config - - -from bbot.modules import module_loader - -available_modules = list(module_loader.configs(type="scan")) -available_output_modules = list(module_loader.configs(type="output")) -available_internal_modules = list(module_loader.configs(type="internal")) - - -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def install_all_python_deps(): deps_pip = set() - for module in module_loader.preloaded().values(): + for module in DEFAULT_PRESET.module_loader.preloaded().values(): deps_pip.update(set(module.get("deps", {}).get("pip", []))) subprocess.run([sys.executable, "-m", "pip", "install"] + list(deps_pip)) - - -class MockResolver: - import dns - - def __init__(self, mock_data=None): - self.mock_data = mock_data if mock_data else {} - self.nameservers = ["127.0.0.1"] - - async def resolve_address(self, ipaddr, *args, **kwargs): - modified_kwargs = {} - modified_kwargs.update(kwargs) - modified_kwargs["rdtype"] = "PTR" - return await self.resolve(str(dns.reversename.from_address(ipaddr)), *args, **modified_kwargs) - - def create_dns_response(self, query_name, rdtype): - query_name = query_name.strip(".") - answers = self.mock_data.get(query_name, {}).get(rdtype, []) - if not answers: - raise self.dns.resolver.NXDOMAIN(f"No answer found for {query_name} {rdtype}") - - message_text = f"""id 1234 -opcode QUERY -rcode NOERROR -flags QR AA RD -;QUESTION -{query_name}. IN {rdtype} -;ANSWER""" - for answer in answers: - message_text += f"\n{query_name}. 1 IN {rdtype} {answer}" - - message_text += "\n;AUTHORITY\n;ADDITIONAL\n" - message = self.dns.message.from_text(message_text) - return message - - async def resolve(self, query_name, rdtype=None): - if rdtype is None: - rdtype = "A" - elif isinstance(rdtype, str): - rdtype = rdtype.upper() - else: - rdtype = str(rdtype.name).upper() - - domain_name = self.dns.name.from_text(query_name) - rdtype_obj = self.dns.rdatatype.from_text(rdtype) - - if "_NXDOMAIN" in self.mock_data and query_name in self.mock_data["_NXDOMAIN"]: - # Simulate the NXDOMAIN exception - raise self.dns.resolver.NXDOMAIN - - try: - response = self.create_dns_response(query_name, rdtype) - answer = self.dns.resolver.Answer(domain_name, rdtype_obj, self.dns.rdataclass.IN, response) - return answer - except self.dns.resolver.NXDOMAIN: - return [] - - -@pytest.fixture() -def mock_dns(): - def _mock_dns(scan, mock_data): - scan.helpers.dns.resolver = MockResolver(mock_data) - - return _mock_dns diff --git a/bbot/test/conftest.py b/bbot/test/conftest.py index ee8a6b2f0..5ae7b028a 100644 --- a/bbot/test/conftest.py +++ b/bbot/test/conftest.py @@ -1,15 +1,34 @@ +import os import ssl import shutil import pytest import asyncio import logging from pathlib import Path +from contextlib import suppress +from omegaconf import OmegaConf from pytest_httpserver import HTTPServer +from bbot.core import CORE from bbot.core.helpers.misc import execute_sync_or_async from bbot.core.helpers.interactsh import server_list as interactsh_servers +test_config = OmegaConf.load(Path(__file__).parent / "test.conf") +if test_config.get("debug", False): + os.environ["BBOT_DEBUG"] = "True" + +if test_config.get("debug", False): + logging.getLogger("bbot").setLevel(logging.DEBUG) +else: + # silence stdout + trace + root_logger = logging.getLogger() + for h in root_logger.handlers: + h.addFilter(lambda x: x.levelname not in ("STDOUT", "TRACE")) + +CORE.merge_default(test_config) + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_sessionfinish(session, exitstatus): # Remove handlers from all loggers to prevent logging errors at exit @@ -25,11 +44,6 @@ def pytest_sessionfinish(session, exitstatus): yield -@pytest.fixture -def non_mocked_hosts() -> list: - return ["127.0.0.1", "localhost", "raw.githubusercontent.com"] + interactsh_servers - - @pytest.fixture def assert_all_responses_were_requested() -> bool: return False @@ -76,6 +90,11 @@ def bbot_httpserver_ssl(): server.clear() +@pytest.fixture +def non_mocked_hosts() -> list: + return ["127.0.0.1", "localhost", "raw.githubusercontent.com"] + interactsh_servers + + @pytest.fixture def bbot_httpserver_allinterfaces(): server = HTTPServer(host="0.0.0.0", port=5556) @@ -90,28 +109,32 @@ def bbot_httpserver_allinterfaces(): server.clear() -@pytest.fixture -def interactsh_mock_instance(): - interactsh_mock = Interactsh_mock() - return interactsh_mock - - class Interactsh_mock: - def __init__(self): + def __init__(self, name): + self.name = name + self.log = logging.getLogger(f"bbot.interactsh.{self.name}") self.interactions = [] self.correlation_id = "deadbeef-dead-beef-dead-beefdeadbeef" self.stop = False + self.poll_task = None - def mock_interaction(self, subdomain_tag): + def mock_interaction(self, subdomain_tag, msg=None): + self.log.info(f"Mocking interaction to subdomain tag: {subdomain_tag}") + if msg is not None: + self.log.info(msg) self.interactions.append(subdomain_tag) async def register(self, callback=None): if callable(callback): - asyncio.create_task(self.poll_loop(callback)) + self.poll_task = asyncio.create_task(self.poll_loop(callback)) return "fakedomain.fakeinteractsh.com" async def deregister(self, callback=None): self.stop = True + if self.poll_task is not None: + self.poll_task.cancel() + with suppress(BaseException): + await self.poll_task async def poll_loop(self, callback=None): while not self.stop: diff --git a/bbot/test/test.conf b/bbot/test/test.conf index ba8367461..5b9f35b45 100644 --- a/bbot/test/test.conf +++ b/bbot/test/test.conf @@ -4,9 +4,6 @@ modules: wordlist: https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/deepmagic.com-prefixes-top500.txt ffuf: prefix_busting: true - ipneighbor: - test_option: ipneighbor -output_modules: http: url: http://127.0.0.1:11111 username: username @@ -17,35 +14,34 @@ output_modules: token: asdf neo4j: uri: bolt://127.0.0.1:11111 - python: - test_option: asdf -internal_modules: - speculate: - test_option: speculate -http_proxy: -http_headers: { "test": "header" } -ssl_verify: false -scope_search_distance: 0 -scope_report_distance: 0 -scope_dns_search_distance: 1 -plumbus: asdf -dns_debug: false -user_agent: "BBOT Test User-Agent" -http_debug: false +web: + http_proxy: + http_headers: { "test": "header" } + ssl_verify: false + user_agent: "BBOT Test User-Agent" + debug: false +scope: + search_distance: 0 + report_distance: 0 +dns: + disable: false + minimal: true + search_distance: 1 + debug: false + timeout: 1 + wildcard_ignore: + - blacklanternsecurity.com + - fakedomain + - notreal + - google + - google.com + - example.com + - evilcorp.com agent_url: ws://127.0.0.1:8765 agent_token: test -dns_resolution: false -dns_timeout: 1 speculate: false excavate: false aggregate: false +cloudcheck: false omit_event_types: [] debug: true -dns_wildcard_ignore: - - blacklanternsecurity.com - - fakedomain - - notreal - - google - - google.com - - example.com - - evilcorp.com diff --git a/bbot/test/test_step_1/test__module__tests.py b/bbot/test/test_step_1/test__module__tests.py index 0d0855557..791e58f58 100644 --- a/bbot/test/test_step_1/test__module__tests.py +++ b/bbot/test/test_step_1/test__module__tests.py @@ -2,8 +2,8 @@ import importlib from pathlib import Path +from bbot import Preset from ..test_step_2.module_tests.base import ModuleTestBase -from bbot.modules import module_loader log = logging.getLogger("bbot.test.modules") @@ -15,8 +15,11 @@ def test__module__tests(): + + preset = Preset() + # make sure each module has a .py file - for module_name in module_loader.preloaded(): + for module_name in preset.module_loader.preloaded(): module_name = module_name.lower() assert module_name in module_test_files, f'No test file found for module "{module_name}"' diff --git a/bbot/test/test_step_1/test_agent.py b/bbot/test/test_step_1/test_agent.py deleted file mode 100644 index 00d70a751..000000000 --- a/bbot/test/test_step_1/test_agent.py +++ /dev/null @@ -1,158 +0,0 @@ -import json -import websockets -from functools import partial - -from ..bbot_fixtures import * # noqa: F401 - - -_first_run = True -success = False - - -async def websocket_handler(websocket, path, scan_done=None): - # whether this is the first run - global _first_run - first_run = int(_first_run) - # whether the test succeeded - global success - # test phase - phase = "ping" - # control channel or event channel? - control = True - - if path == "/control/" and first_run: - # test ping - await websocket.send(json.dumps({"conversation": "90196cc1-299f-4555-82a0-bc22a4247590", "command": "ping"})) - _first_run = False - else: - control = False - - # Bearer token - assert websocket.request_headers["Authorization"] == "Bearer test" - - async for message in websocket: - log.debug(f"PHASE: {phase}, MESSAGE: {message}") - if not control or not first_run: - continue - m = json.loads(message) - # ping - if phase == "ping": - assert json.loads(message)["message_type"] == "pong" - phase = "start_scan_bad" - if phase == "start_scan_bad": - await websocket.send( - json.dumps( - { - "conversation": "90196cc1-299f-4555-82a0-bc22a4247590", - "command": "start_scan", - "arguments": { - "scan_id": "90196cc1-299f-4555-82a0-bc22a4247590", - "targets": ["127.0.0.2"], - "modules": ["asdf"], - "output_modules": ["human"], - "name": "agent_test_scan_bad", - }, - } - ) - ) - phase = "success" - continue - # scan start success - if phase == "success": - assert m["message"]["success"] == "Started scan" - phase = "cleaning_up" - continue - # CLEANING_UP status message - if phase == "cleaning_up": - assert m["message_type"] == "scan_status_change" - assert m["status"] == "CLEANING_UP" - phase = "failed" - continue - # FAILED status message - if phase == "failed": - assert m["message_type"] == "scan_status_change" - assert m["status"] == "FAILED" - phase = "start_scan" - # start good scan - if phase == "start_scan": - await websocket.send( - json.dumps( - { - "conversation": "90196cc1-299f-4555-82a0-bc22a4247590", - "command": "start_scan", - "arguments": { - "scan_id": "90196cc1-299f-4555-82a0-bc22a4247590", - "targets": ["127.0.0.2"], - "modules": ["ipneighbor"], - "output_modules": ["human"], - "name": "agent_test_scan", - }, - } - ) - ) - phase = "success_2" - continue - # scan start success - if phase == "success_2": - assert m["message"]["success"] == "Started scan" - phase = "starting" - continue - # STARTING status message - if phase == "starting": - assert m["message_type"] == "scan_status_change" - assert m["status"] == "STARTING" - phase = "running" - continue - # RUNNING status message - if phase == "running": - assert m["message_type"] == "scan_status_change" - assert m["status"] == "RUNNING" - phase = "finishing" - continue - # FINISHING status message - if phase == "finishing": - assert m["message_type"] == "scan_status_change" - assert m["status"] == "FINISHING" - phase = "cleaning_up_2" - continue - # CLEANING_UP status message - if phase == "cleaning_up_2": - assert m["message_type"] == "scan_status_change" - assert m["status"] == "CLEANING_UP" - phase = "finished_2" - continue - # FINISHED status message - if phase == "finished_2": - assert m["message_type"] == "scan_status_change" - assert m["status"] == "FINISHED" - success = True - scan_done.set() - break - - -@pytest.mark.asyncio -async def test_agent(agent): - scan_done = asyncio.Event() - scan_status = await agent.scan_status() - assert scan_status["error"] == "Scan not in progress" - - _websocket_handler = partial(websocket_handler, scan_done=scan_done) - - global success - async with websockets.serve(_websocket_handler, "127.0.0.1", 8765): - agent_task = asyncio.create_task(agent.start()) - # wait for 90 seconds - await asyncio.wait_for(scan_done.wait(), 60) - assert success - - await agent.start_scan("scan_to_be_cancelled", targets=["127.0.0.1"], modules=["ipneighbor"]) - await agent.start_scan("scan_to_be_rejected", targets=["127.0.0.1"], modules=["ipneighbor"]) - await asyncio.sleep(0.1) - await agent.stop_scan() - tasks = [agent.task, agent_task] - for task in tasks: - try: - task.cancel() - await task - except (asyncio.CancelledError, AttributeError): - pass diff --git a/bbot/test/test_step_1/test_bloom_filter.py b/bbot/test/test_step_1/test_bloom_filter.py new file mode 100644 index 000000000..6d8e6918d --- /dev/null +++ b/bbot/test/test_step_1/test_bloom_filter.py @@ -0,0 +1,65 @@ +import time +import string +import random + + +def test_bloom_filter(): + + def generate_random_strings(n, length=10): + """Generate a list of n random strings.""" + return ["".join(random.choices(string.ascii_letters + string.digits, k=length)) for _ in range(n)] + + from bbot.scanner import Scanner + + scan = Scanner() + + n_items_to_add = 100000 + n_items_to_test = 100000 + bloom_filter_size = 8000000 + + # Initialize the simple bloom filter and the set + bloom_filter = scan.helpers.bloom_filter(size=bloom_filter_size) + + test_set = set() + + # Generate random strings to add + print(f"Generating {n_items_to_add:,} items to add") + items_to_add = set(generate_random_strings(n_items_to_add)) + + # Generate random strings to test + print(f"Generating {n_items_to_test:,} items to test") + items_to_test = generate_random_strings(n_items_to_test) + + print("Adding items") + start = time.time() + for item in items_to_add: + bloom_filter.add(item) + test_set.add(hash(item)) + end = time.time() + elapsed = end - start + print(f"elapsed: {elapsed:.2f} ({int(n_items_to_test/elapsed)}/s)") + # this shouldn't take longer than 5 seconds + assert elapsed < 5 + + # make sure we have 100% accuracy + start = time.time() + for item in items_to_add: + assert item in bloom_filter + end = time.time() + elapsed = end - start + print(f"elapsed: {elapsed:.2f} ({int(n_items_to_test/elapsed)}/s)") + # this shouldn't take longer than 5 seconds + assert elapsed < 5 + + print("Measuring false positives") + # Check for false positives + false_positives = 0 + for item in items_to_test: + if bloom_filter.check(item) and hash(item) not in test_set: + false_positives += 1 + false_positive_percent = false_positives / len(items_to_test) * 100 + + print(f"False positive rate: {false_positive_percent:.2f}% ({false_positives}/{len(items_to_test)})") + + # ensure false positives are less than .02 percent + assert false_positive_percent < 0.02 diff --git a/bbot/test/test_step_1/test_cli.py b/bbot/test/test_step_1/test_cli.py index 0ccc94887..52b3867fe 100644 --- a/bbot/test/test_step_1/test_cli.py +++ b/bbot/test/test_step_1/test_cli.py @@ -1,35 +1,118 @@ from ..bbot_fixtures import * +from bbot import cli + @pytest.mark.asyncio -async def test_cli(monkeypatch, bbot_config): - from bbot import cli +async def test_cli_scope(monkeypatch, capsys): + import json monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) - monkeypatch.setattr(cli, "config", bbot_config) - old_sys_argv = sys.argv + # basic target without whitelist + monkeypatch.setattr( + "sys.argv", + ["bbot", "-t", "one.one.one.one", "-c", "scope.report_distance=10", "dns.minimal=false", "--json"], + ) + result = await cli._main() + out, err = capsys.readouterr() + assert result == True + lines = [json.loads(l) for l in out.splitlines()] + dns_events = [l for l in lines if l["type"] == "DNS_NAME" and l["data"] == "one.one.one.one"] + assert dns_events + assert all([l["scope_distance"] == 0 and "in-scope" 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"] == 0 + and "in-scope" in l["tags"] + and "target" in l["tags"] + ] + ) + 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"] == 1 and "distance-1" in l["tags"] for l in ip_events]) + ip_events = [l for l in lines if l["type"] == "IP_ADDRESS" and l["data"] == "1.0.0.1"] + assert ip_events + assert all([l["scope_distance"] == 1 and "distance-1" in l["tags"] for l in ip_events]) + + # with whitelist + monkeypatch.setattr( + "sys.argv", + [ + "bbot", + "-t", + "one.one.one.one", + "-w", + "192.168.0.1", + "-c", + "scope.report_distance=10", + "dns.minimal=false", + "dns.search_distance=2", + "--json", + ], + ) + result = await cli._main() + out, err = capsys.readouterr() + assert result == True + lines = [json.loads(l) for l in out.splitlines()] + lines = [l for l in lines if l["type"] != "SCAN"] + assert lines + 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 + 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"] + ] + ) + 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]) + ip_events = [l for l in lines if l["type"] == "IP_ADDRESS" and l["data"] == "1.0.0.1"] + assert ip_events + assert all([l["scope_distance"] == 2 and "distance-2" in l["tags"] for l in ip_events]) + + +@pytest.mark.asyncio +async def test_cli_scan(monkeypatch): + monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) + monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) - home_dir = Path(bbot_config["home"]) - scans_home = home_dir / "scans" + scans_home = bbot_test_dir / "scans" # basic scan monkeypatch.setattr( sys, "argv", - ["bbot", "-y", "-t", "127.0.0.1", "www.example.com", "-n", "test_cli_scan", "-c", "dns_resolution=False"], + ["bbot", "-y", "-t", "127.0.0.1", "www.example.com", "-n", "test_cli_scan", "-c", "dns.disable=true"], ) - await cli._main() + result = await cli._main() + assert result == True scan_home = scans_home / "test_cli_scan" + assert (scan_home / "preset.yml").is_file(), "preset.yml not found" assert (scan_home / "wordcloud.tsv").is_file(), "wordcloud.tsv not found" assert (scan_home / "output.txt").is_file(), "output.txt not found" assert (scan_home / "output.csv").is_file(), "output.csv not found" - assert (scan_home / "output.ndjson").is_file(), "output.ndjson not found" + assert (scan_home / "output.json").is_file(), "output.json not found" + + with open(scan_home / "preset.yml") as f: + text = f.read() + assert " dns:\n disable: true" in text + with open(scan_home / "output.csv") as f: lines = f.readlines() - assert lines[0] == "Event type,Event data,IP Address,Source Module,Scope Distance,Event Tags\n" + assert lines[0] == "Event type,Event data,IP Address,Source Module,Scope Distance,Event Tags,Discovery Path\n" assert len(lines) > 1, "output.csv is not long enough" ip_success = False @@ -44,146 +127,537 @@ async def test_cli(monkeypatch, bbot_config): dns_success = True assert ip_success and dns_success, "IP_ADDRESS and/or DNS_NAME are not present in output.txt" + +@pytest.mark.asyncio +async def test_cli_args(monkeypatch, caplog, capsys, clean_default_config): + caplog.set_level(logging.INFO) + + monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) + monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) + # show version monkeypatch.setattr("sys.argv", ["bbot", "--version"]) - await cli._main() - - # start agent - monkeypatch.setattr("sys.argv", ["bbot", "--agent-mode"]) - task = asyncio.create_task(cli._main()) - await asyncio.sleep(2) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert len(out.splitlines()) == 1 + assert out.count(".") > 1 + + # list modules + monkeypatch.setattr("sys.argv", ["bbot", "--list-modules"]) + result = await cli._main() + assert result == None + out, err = capsys.readouterr() + # internal modules + assert "| excavate " in out + # output modules + assert "| csv " in out + # scan modules + assert "| wayback " in out + + # output dir and scan name + output_dir = bbot_test_dir / "bbot_cli_args_output" + scan_name = "bbot_cli_args_scan_name" + scan_dir = output_dir / scan_name + assert not output_dir.exists() + monkeypatch.setattr("sys.argv", ["bbot", "-o", str(output_dir), "-n", scan_name, "-y"]) + result = await cli._main() + assert result == True + assert output_dir.is_dir() + assert scan_dir.is_dir() + assert "[SCAN]" in open(scan_dir / "output.txt").read() + assert "[INFO]" in open(scan_dir / "scan.log").read() + shutil.rmtree(output_dir) + + # list module options + monkeypatch.setattr("sys.argv", ["bbot", "--list-module-options"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| modules.wayback.urls" in out + assert "| bool" in out + assert "| emit URLs in addition to DNS_NAMEs" in out + assert "| False" in out + assert "| modules.dnsbrute.wordlist" in out + assert "| modules.robots.include_allow" in out + + # list module options by flag + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "--list-module-options"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| modules.wayback.urls" in out + assert "| bool" in out + assert "| emit URLs in addition to DNS_NAMEs" in out + assert "| False" in out + assert "| modules.dnsbrute.wordlist" in out + assert not "| modules.robots.include_allow" in out + + # list module options by module + monkeypatch.setattr("sys.argv", ["bbot", "-m", "dnsbrute", "-lmo"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert out.count("modules.") == out.count("modules.dnsbrute.") + assert not "| modules.wayback.urls" in out + assert "| modules.dnsbrute.wordlist" in out + assert not "| modules.robots.include_allow" in out + + # list output module options by module + monkeypatch.setattr("sys.argv", ["bbot", "-om", "stdout", "-lmo"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert out.count("modules.") == out.count("modules.stdout.") + + # list flags + monkeypatch.setattr("sys.argv", ["bbot", "--list-flags"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| safe " in out + assert "| Non-intrusive, safe to run " in out + assert "| active " in out + assert "| passive " in out + + # list only a single flag + monkeypatch.setattr("sys.argv", ["bbot", "-f", "active", "--list-flags"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert not "| safe " in out + assert "| active " in out + assert not "| passive " in out + + # list multiple flags + monkeypatch.setattr("sys.argv", ["bbot", "-f", "active", "safe", "--list-flags"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| safe " in out + assert "| active " in out + assert not "| passive " in out # no args monkeypatch.setattr("sys.argv", ["bbot"]) - await cli._main() + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "Target:\n -t TARGET [TARGET ...]" in out + + # list modules + monkeypatch.setattr("sys.argv", ["bbot", "-l"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| dnsbrute " in out + assert "| httpx " in out + assert "| robots " in out + + # list modules by flag + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-l"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| dnsbrute " in out + assert "| httpx " in out + assert not "| robots " in out + + # list modules by flag + required flag + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-rf", "passive", "-l"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| dnsbrute " in out + assert not "| httpx " in out - # enable module by flag - monkeypatch.setattr("sys.argv", ["bbot", "-f", "report"]) - await cli._main() + # list modules by flag + excluded flag + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-ef", "active", "-l"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert "| dnsbrute " in out + assert not "| httpx " in out + + # list modules by flag + excluded module + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomain-enum", "-em", "dnsbrute", "-l"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == None + assert not "| dnsbrute " in out + assert "| httpx " in out + + # output modules override + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-om", "csv,json", "-y"]) + result = await cli._main() + assert result == True + assert "Loaded 2/2 output modules, (csv,json)" in caplog.text + caplog.clear() + monkeypatch.setattr("sys.argv", ["bbot", "-em", "csv,json", "-y"]) + result = await cli._main() + assert result == True + assert "Loaded 3/3 output modules, (python,stdout,txt)" in caplog.text + + # output modules override + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-om", "subdomains", "-y"]) + result = await cli._main() + assert result == True + assert "Loaded 6/6 output modules, (csv,json,python,stdout,subdomains,txt)" in caplog.text + + # internal modules override + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-y"]) + result = await cli._main() + assert result == True + assert "Loaded 5/5 internal modules (aggregate,cloudcheck,dnsresolve,excavate,speculate)" in caplog.text + caplog.clear() + monkeypatch.setattr("sys.argv", ["bbot", "-em", "excavate", "speculate", "-y"]) + result = await cli._main() + assert result == True + assert "Loaded 3/3 internal modules (aggregate,cloudcheck,dnsresolve)" in caplog.text + caplog.clear() + monkeypatch.setattr("sys.argv", ["bbot", "-c", "speculate=false", "-y"]) + result = await cli._main() + assert result == True + assert "Loaded 4/4 internal modules (aggregate,cloudcheck,dnsresolve,excavate)" in caplog.text + + # custom target type + out, err = capsys.readouterr() + monkeypatch.setattr("sys.argv", ["bbot", "-t", "ORG:evilcorp", "-y"]) + result = await cli._main() + out, err = capsys.readouterr() + assert result == True + assert "[ORG_STUB] evilcorp TARGET" in out + + # activate modules by flag + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-f", "passive"]) + result = await cli._main() + assert result == True # unconsoleable output module monkeypatch.setattr("sys.argv", ["bbot", "-om", "web_report"]) - await cli._main() - - # install all deps - monkeypatch.setattr("sys.argv", ["bbot", "--install-all-deps"]) - success = await cli._main() - assert success, "--install-all-deps failed for at least one module" + result = await cli._main() + assert result == True # unresolved dependency monkeypatch.setattr("sys.argv", ["bbot", "-m", "wappalyzer"]) - await cli._main() + result = await cli._main() + assert result == True - # resolved dependency, excluded module + # enable and exclude the same module + caplog.clear() + assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-m", "ffuf_shortnames", "-em", "ffuf_shortnames"]) - await cli._main() + result = await cli._main() + assert result == None + assert 'Unable to add scan module "ffuf_shortnames" because the module has been excluded' in caplog.text # require flags monkeypatch.setattr("sys.argv", ["bbot", "-f", "active", "-rf", "passive"]) - await cli._main() + result = await cli._main() + assert result == True # excluded flags monkeypatch.setattr("sys.argv", ["bbot", "-f", "active", "-ef", "active"]) - await cli._main() + result = await cli._main() + assert result == True # slow modules - monkeypatch.setattr("sys.argv", ["bbot", "-m", "massdns"]) - await cli._main() + monkeypatch.setattr("sys.argv", ["bbot", "-m", "bucket_digitalocean"]) + result = await cli._main() + assert result == True # deadly modules + caplog.clear() + assert not caplog.text monkeypatch.setattr("sys.argv", ["bbot", "-m", "nuclei"]) result = await cli._main() assert result == False, "-m nuclei ran without --allow-deadly" + assert "Please specify --allow-deadly to continue" in caplog.text # --allow-deadly monkeypatch.setattr("sys.argv", ["bbot", "-m", "nuclei", "--allow-deadly"]) result = await cli._main() - assert result != False, "-m nuclei failed to run with --allow-deadly" + assert result == True, "-m nuclei failed to run with --allow-deadly" - # show current config - monkeypatch.setattr("sys.argv", ["bbot", "-y", "--current-config"]) - await cli._main() + # install all deps + monkeypatch.setattr("sys.argv", ["bbot", "--install-all-deps"]) + success = await cli._main() + assert success == True, "--install-all-deps failed for at least one module" - # list modules - monkeypatch.setattr("sys.argv", ["bbot", "-l"]) - await cli._main() - # list module options - monkeypatch.setattr("sys.argv", ["bbot", "--help-all"]) - await cli._main() +@pytest.mark.asyncio +async def test_cli_customheaders(monkeypatch, caplog, capsys): + monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) + monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) + import yaml + + # test custom headers + monkeypatch.setattr( + "sys.argv", ["bbot", "--custom-headers", "foo=bar", "foo2=bar2", "foo3=bar=3", "--current-preset"] + ) + success = await cli._main() + assert success == None, "setting custom headers on command line failed" + captured = capsys.readouterr() + stdout_preset = yaml.safe_load(captured.out) + assert stdout_preset["config"]["web"]["http_headers"] == {"foo": "bar", "foo2": "bar2", "foo3": "bar=3"} - # unpatch sys.argv - monkeypatch.setattr("sys.argv", old_sys_argv) + # test custom headers invalid (no "=") + monkeypatch.setattr("sys.argv", ["bbot", "--custom-headers", "justastring", "--current-preset"]) + result = await cli._main() + assert result == None + assert "Custom headers not formatted correctly (missing '=')" in caplog.text + caplog.clear() + # test custom headers invalid (missing key) + monkeypatch.setattr("sys.argv", ["bbot", "--custom-headers", "=nokey", "--current-preset"]) + result = await cli._main() + assert result == None + assert "Custom headers not formatted correctly (missing header name or value)" in caplog.text + caplog.clear() + + # test custom headers invalid (missing value) + monkeypatch.setattr("sys.argv", ["bbot", "--custom-headers", "missingvalue=", "--current-preset"]) + result = await cli._main() + assert result == None + assert "Custom headers not formatted correctly (missing header name or value)" in caplog.text -def test_config_validation(monkeypatch, capsys, bbot_config): - from bbot import cli - from bbot.core.configurator import args +def test_cli_config_validation(monkeypatch, caplog): monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) - monkeypatch.setattr(cli, "config", bbot_config) - - old_cli_config = args.cli_config # incorrect module option - monkeypatch.setattr(args, "cli_config", ["bbot", "-c", "modules.ipnegibhor.num_bits=4"]) + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-c", "modules.ipnegibhor.num_bits=4"]) cli.main() - captured = capsys.readouterr() - assert 'Could not find module option "modules.ipnegibhor.num_bits"' in captured.err - assert 'Did you mean "modules.ipneighbor.num_bits"?' in captured.err + assert 'Could not find config option "modules.ipnegibhor.num_bits"' in caplog.text + assert 'Did you mean "modules.ipneighbor.num_bits"?' in caplog.text # incorrect global option - monkeypatch.setattr(args, "cli_config", ["bbot", "-c", "web_spier_distance=4"]) + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-c", "web_spier_distance=4"]) cli.main() - captured = capsys.readouterr() - assert 'Could not find module option "web_spier_distance"' in captured.err - assert 'Did you mean "web_spider_distance"?' in captured.err + assert 'Could not find config option "web_spier_distance"' in caplog.text + assert 'Did you mean "web.spider_distance"?' in caplog.text + + +def test_cli_module_validation(monkeypatch, caplog): + monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) + monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) + + # incorrect module + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-m", "dnsbrutes"]) + cli.main() + assert 'Could not find scan module "dnsbrutes"' in caplog.text + assert 'Did you mean "dnsbrute"?' in caplog.text + + # incorrect excluded module + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-em", "dnsbrutes"]) + cli.main() + assert 'Could not find module "dnsbrutes"' in caplog.text + assert 'Did you mean "dnsbrute"?' in caplog.text + + # incorrect output module + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-om", "neoo4j"]) + cli.main() + assert 'Could not find output module "neoo4j"' in caplog.text + assert 'Did you mean "neo4j"?' in caplog.text + + # output module setup failed + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-om", "websocket", "-c", "modules.websocket.url=", "-y"]) + cli.main() + lines = caplog.text.splitlines() + assert "Loaded 6/6 output modules, (csv,json,python,stdout,txt,websocket)" in caplog.text + assert 1 == len( + [ + l + for l in lines + if l.startswith("WARNING bbot.scanner:scanner.py") + and l.endswith("Setup hard-failed for websocket: Must set URL") + ] + ) + assert 1 == len( + [ + l + for l in lines + if l.startswith("WARNING bbot.modules.output.websocket:base.py") and l.endswith("Setting error state") + ] + ) + assert 1 == len( + [ + l + for l in lines + if l.startswith("ERROR bbot.cli:cli.py") + and l.endswith("Setup hard-failed for 1 modules (websocket) (--force to run module anyway)") + ] + ) + + # only output module setup failed + caplog.clear() + assert not caplog.text + monkeypatch.setattr( + "sys.argv", + ["bbot", "-om", "websocket", "-em", "python,stdout,csv,json,txt", "-c", "modules.websocket.url=", "-y"], + ) + cli.main() + lines = caplog.text.splitlines() + assert "Loaded 1/1 output modules, (websocket)" in caplog.text + assert 1 == len( + [ + l + for l in lines + if l.startswith("WARNING bbot.scanner:scanner.py") + and l.endswith("Setup hard-failed for websocket: Must set URL") + ] + ) + assert 1 == len( + [ + l + for l in lines + if l.startswith("WARNING bbot.modules.output.websocket:base.py") and l.endswith("Setting error state") + ] + ) + assert 1 == len( + [ + l + for l in lines + if l.startswith("ERROR bbot.cli:cli.py") and l.endswith("Failed to load output modules. Aborting.") + ] + ) - # unpatch cli_options - monkeypatch.setattr(args, "cli_config", old_cli_config) + # incorrect flag + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-f", "subdomainenum"]) + cli.main() + assert 'Could not find flag "subdomainenum"' in caplog.text + assert 'Did you mean "subdomain-enum"?' in caplog.text + # incorrect excluded flag + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-ef", "subdomainenum"]) + cli.main() + assert 'Could not find flag "subdomainenum"' in caplog.text + assert 'Did you mean "subdomain-enum"?' in caplog.text + + # incorrect required flag + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-rf", "subdomainenum"]) + cli.main() + assert 'Could not find flag "subdomainenum"' in caplog.text + assert 'Did you mean "subdomain-enum"?' in caplog.text -def test_module_validation(monkeypatch, capsys, bbot_config): - from bbot.core.configurator import args + +def test_cli_presets(monkeypatch, capsys, caplog): + import yaml monkeypatch.setattr(sys, "exit", lambda *args, **kwargs: True) monkeypatch.setattr(os, "_exit", lambda *args, **kwargs: True) - old_sys_argv = sys.argv + # show current preset + monkeypatch.setattr("sys.argv", ["bbot", "-c", "web.http_proxy=currentpresettest", "--current-preset"]) + cli.main() + captured = capsys.readouterr() + assert " http_proxy: currentpresettest" in captured.out - # incorrect module - monkeypatch.setattr(sys, "argv", ["bbot", "-m", "massdnss"]) - args.parser.parse_args() + # show current preset (full) + monkeypatch.setattr("sys.argv", ["bbot", "-c" "modules.c99.api_key=asdf", "--current-preset-full"]) + cli.main() + captured = capsys.readouterr() + assert " api_key: asdf" in captured.out + + preset_dir = bbot_test_dir / "test_cli_presets" + preset_dir.mkdir(exist_ok=True) + + preset1_file = preset_dir / "cli_preset1.conf" + with open(preset1_file, "w") as f: + f.write( + """ +config: + web: + http_proxy: http://proxy1 + """ + ) + + preset2_file = preset_dir / "cli_preset2.yml" + with open(preset2_file, "w") as f: + f.write( + """ +config: + web: + http_proxy: http://proxy2 + """ + ) + + # test reading single preset + monkeypatch.setattr("sys.argv", ["bbot", "-p", str(preset1_file.resolve()), "--current-preset"]) + cli.main() captured = capsys.readouterr() - assert 'Could not find module "massdnss"' in captured.err - assert 'Did you mean "massdns"?' in captured.err + stdout_preset = yaml.safe_load(captured.out) + assert stdout_preset["config"]["web"]["http_proxy"] == "http://proxy1" - # incorrect excluded module - monkeypatch.setattr(sys, "argv", ["bbot", "-em", "massdnss"]) - args.parser.parse_args() + # preset overrides preset + monkeypatch.setattr( + "sys.argv", ["bbot", "-p", str(preset2_file.resolve()), str(preset1_file.resolve()), "--current-preset"] + ) + cli.main() captured = capsys.readouterr() - assert 'Could not find module "massdnss"' in captured.err - assert 'Did you mean "massdns"?' in captured.err + stdout_preset = yaml.safe_load(captured.out) + assert stdout_preset["config"]["web"]["http_proxy"] == "http://proxy1" - # incorrect output module - monkeypatch.setattr(sys, "argv", ["bbot", "-om", "neoo4j"]) - args.parser.parse_args() + # override other way + monkeypatch.setattr( + "sys.argv", ["bbot", "-p", str(preset1_file.resolve()), str(preset2_file.resolve()), "--current-preset"] + ) + cli.main() captured = capsys.readouterr() - assert 'Could not find output module "neoo4j"' in captured.err - assert 'Did you mean "neo4j"?' in captured.err + stdout_preset = yaml.safe_load(captured.out) + assert stdout_preset["config"]["web"]["http_proxy"] == "http://proxy2" - # incorrect flag - monkeypatch.setattr(sys, "argv", ["bbot", "-f", "subdomainenum"]) - args.parser.parse_args() + # cli config overrides all presets + monkeypatch.setattr( + "sys.argv", + [ + "bbot", + "-p", + str(preset1_file.resolve()), + str(preset2_file.resolve()), + "-c", + "web.http_proxy=asdf", + "--current-preset", + ], + ) + cli.main() captured = capsys.readouterr() - assert 'Could not find flag "subdomainenum"' in captured.err - assert 'Did you mean "subdomain-enum"?' in captured.err + stdout_preset = yaml.safe_load(captured.out) + assert stdout_preset["config"]["web"]["http_proxy"] == "asdf" + + # invalid preset + caplog.clear() + assert not caplog.text + monkeypatch.setattr("sys.argv", ["bbot", "-p", "asdfasdfasdf", "-y"]) + cli.main() + assert "file does not exist. Use -lp to list available presets" in caplog.text - # unpatch sys.argv - monkeypatch.setattr("sys.argv", old_sys_argv) + preset1_file.unlink() + preset2_file.unlink() diff --git a/bbot/test/test_step_1/test_cloud_helpers.py b/bbot/test/test_step_1/test_cloud_helpers.py deleted file mode 100644 index b42da11a7..000000000 --- a/bbot/test/test_step_1/test_cloud_helpers.py +++ /dev/null @@ -1,86 +0,0 @@ -from ..bbot_fixtures import * # noqa: F401 - - -@pytest.mark.asyncio -async def test_cloud_helpers(bbot_scanner, bbot_config): - scan1 = bbot_scanner("127.0.0.1", config=bbot_config) - - provider_names = ("amazon", "google", "azure", "digitalocean", "oracle", "akamai", "cloudflare", "github") - for provider_name in provider_names: - assert provider_name in scan1.helpers.cloud.providers.providers - - for p in scan1.helpers.cloud.providers.providers.values(): - print(f"{p.name}: {p.domains} / {p.ranges}") - amazon_ranges = list(scan1.helpers.cloud["amazon"].ranges) - assert amazon_ranges - amazon_range = next(iter(amazon_ranges)) - amazon_address = amazon_range.broadcast_address - - ip_event = scan1.make_event(amazon_address, source=scan1.root_event) - aws_event1 = scan1.make_event("amazonaws.com", source=scan1.root_event) - aws_event2 = scan1.make_event("asdf.amazonaws.com", source=scan1.root_event) - aws_event3 = scan1.make_event("asdfamazonaws.com", source=scan1.root_event) - aws_event4 = scan1.make_event("test.asdf.aws", source=scan1.root_event) - - other_event1 = scan1.make_event("cname.evilcorp.com", source=scan1.root_event) - other_event2 = scan1.make_event("cname2.evilcorp.com", source=scan1.root_event) - other_event3 = scan1.make_event("cname3.evilcorp.com", source=scan1.root_event) - other_event2._resolved_hosts = {amazon_address} - other_event3._resolved_hosts = {"asdf.amazonaws.com"} - - for event in (ip_event, aws_event1, aws_event2, aws_event4, other_event2, other_event3): - await scan1.helpers.cloud.tag_event(event) - assert "cloud-amazon" in event.tags, f"{event} was not properly cloud-tagged" - - for event in (aws_event3, other_event1): - await scan1.helpers.cloud.tag_event(event) - assert "cloud-amazon" not in event.tags, f"{event} was improperly cloud-tagged" - assert not any( - t for t in event.tags if t.startswith("cloud-") or t.startswith("cdn-") - ), f"{event} was improperly cloud-tagged" - - google_event1 = scan1.make_event("asdf.googleapis.com", source=scan1.root_event) - google_event2 = scan1.make_event("asdf.google", source=scan1.root_event) - google_event3 = scan1.make_event("asdf.evilcorp.com", source=scan1.root_event) - google_event3._resolved_hosts = {"asdf.storage.googleapis.com"} - - for event in (google_event1, google_event2, google_event3): - await scan1.helpers.cloud.tag_event(event) - assert "cloud-google" in event.tags, f"{event} was not properly cloud-tagged" - assert "cloud-storage-bucket" in google_event3.tags - - -@pytest.mark.asyncio -async def test_cloud_helpers_excavate(bbot_scanner, bbot_config, bbot_httpserver): - url = bbot_httpserver.url_for("/test_cloud_helpers_excavate") - bbot_httpserver.expect_request(uri="/test_cloud_helpers_excavate").respond_with_data( - "
" - ) - scan1 = bbot_scanner(url, modules=["httpx", "excavate"], config=bbot_config) - events = [e async for e in scan1.async_start()] - assert 1 == len( - [ - e - for e in events - if e.type == "STORAGE_BUCKET" - and e.data["name"] == "asdf" - and "cloud-amazon" in e.tags - and "cloud-storage-bucket" in e.tags - ] - ) - - -@pytest.mark.asyncio -async def test_cloud_helpers_speculate(bbot_scanner, bbot_config): - scan1 = bbot_scanner("asdf.s3.amazonaws.com", modules=["speculate"], config=bbot_config) - events = [e async for e in scan1.async_start()] - assert 1 == len( - [ - e - for e in events - if e.type == "STORAGE_BUCKET" - and e.data["name"] == "asdf" - and "cloud-amazon" in e.tags - and "cloud-storage-bucket" in e.tags - ] - ) diff --git a/bbot/test/test_step_1/test_command.py b/bbot/test/test_step_1/test_command.py index 42e41e4da..31abef19b 100644 --- a/bbot/test/test_step_1/test_command.py +++ b/bbot/test/test_step_1/test_command.py @@ -4,8 +4,8 @@ @pytest.mark.asyncio -async def test_command(bbot_scanner, bbot_config): - scan1 = bbot_scanner(config=bbot_config) +async def test_command(bbot_scanner): + scan1 = bbot_scanner() # test timeouts command = ["sleep", "3"] @@ -78,10 +78,10 @@ async def test_command(bbot_scanner, bbot_config): # test check=True with pytest.raises(CalledProcessError) as excinfo: - lines = [l async for line in scan1.helpers.run_live(["ls", "/aslkdjflasdkfsd"], check=True)] + lines = [line async for line in scan1.helpers.run_live(["ls", "/aslkdjflasdkfsd"], check=True)] assert "No such file or directory" in excinfo.value.stderr with pytest.raises(CalledProcessError) as excinfo: - lines = [l async for line in scan1.helpers.run_live(["ls", "/aslkdjflasdkfsd"], check=True, text=False)] + lines = [line async for line in scan1.helpers.run_live(["ls", "/aslkdjflasdkfsd"], check=True, text=False)] assert b"No such file or directory" in excinfo.value.stderr with pytest.raises(CalledProcessError) as excinfo: await scan1.helpers.run(["ls", "/aslkdjflasdkfsd"], check=True) @@ -116,29 +116,29 @@ async def test_command(bbot_scanner, bbot_config): assert not lines # test sudo + existence of environment variables - scan1.load_modules() + await scan1.load_modules() path_parts = os.environ.get("PATH", "").split(":") assert "/tmp/.bbot_test/tools" in path_parts run_lines = (await scan1.helpers.run(["env"])).stdout.splitlines() - assert "BBOT_PLUMBUS=asdf" in run_lines + assert "BBOT_WEB_USER_AGENT=BBOT Test User-Agent" in run_lines for line in run_lines: if line.startswith("PATH="): path_parts = line.split("=", 1)[-1].split(":") assert "/tmp/.bbot_test/tools" in path_parts run_lines_sudo = (await scan1.helpers.run(["env"], sudo=True)).stdout.splitlines() - assert "BBOT_PLUMBUS=asdf" in run_lines_sudo + assert "BBOT_WEB_USER_AGENT=BBOT Test User-Agent" in run_lines_sudo for line in run_lines_sudo: if line.startswith("PATH="): path_parts = line.split("=", 1)[-1].split(":") assert "/tmp/.bbot_test/tools" in path_parts run_live_lines = [l async for l in scan1.helpers.run_live(["env"])] - assert "BBOT_PLUMBUS=asdf" in run_live_lines + assert "BBOT_WEB_USER_AGENT=BBOT Test User-Agent" in run_live_lines for line in run_live_lines: if line.startswith("PATH="): path_parts = line.strip().split("=", 1)[-1].split(":") assert "/tmp/.bbot_test/tools" in path_parts run_live_lines_sudo = [l async for l in scan1.helpers.run_live(["env"], sudo=True)] - assert "BBOT_PLUMBUS=asdf" in run_live_lines_sudo + assert "BBOT_WEB_USER_AGENT=BBOT Test User-Agent" in run_live_lines_sudo for line in run_live_lines_sudo: if line.startswith("PATH="): path_parts = line.strip().split("=", 1)[-1].split(":") diff --git a/bbot/test/test_step_1/test_config.py b/bbot/test/test_step_1/test_config.py index 2d9980a2c..b237fcae4 100644 --- a/bbot/test/test_step_1/test_config.py +++ b/bbot/test/test_step_1/test_config.py @@ -2,9 +2,21 @@ @pytest.mark.asyncio -async def test_config(bbot_config, bbot_scanner): - scan1 = bbot_scanner("127.0.0.1", modules=["ipneighbor", "speculate"], config=bbot_config) +async def test_config(bbot_scanner): + config = OmegaConf.create( + { + "plumbus": "asdf", + "speculate": True, + "modules": { + "ipneighbor": {"test_option": "ipneighbor"}, + "python": {"test_option": "asdf"}, + "speculate": {"test_option": "speculate"}, + }, + } + ) + scan1 = bbot_scanner("127.0.0.1", modules=["ipneighbor"], config=config) await scan1.load_modules() + assert scan1.config.web.user_agent == "BBOT Test User-Agent" assert scan1.config.plumbus == "asdf" assert scan1.modules["ipneighbor"].config.test_option == "ipneighbor" assert scan1.modules["python"].config.test_option == "asdf" diff --git a/bbot/test/test_step_1/test_depsinstaller.py b/bbot/test/test_step_1/test_depsinstaller.py index 39a56bf41..e3f80d5cf 100644 --- a/bbot/test/test_step_1/test_depsinstaller.py +++ b/bbot/test/test_step_1/test_depsinstaller.py @@ -1,11 +1,9 @@ from ..bbot_fixtures import * -def test_depsinstaller(monkeypatch, bbot_config, bbot_scanner): +def test_depsinstaller(monkeypatch, bbot_scanner): scan = bbot_scanner( "127.0.0.1", - modules=["dnsresolve"], - config=bbot_config, ) # test shell diff --git a/bbot/test/test_step_1/test_dns.py b/bbot/test/test_step_1/test_dns.py index 91465507e..ae24c6c25 100644 --- a/bbot/test/test_step_1/test_dns.py +++ b/bbot/test/test_step_1/test_dns.py @@ -1,92 +1,162 @@ from ..bbot_fixtures import * +from bbot.core.helpers.dns.helpers import extract_targets + + +mock_records = { + "one.one.one.one": { + "A": ["1.1.1.1", "1.0.0.1"], + "AAAA": ["2606:4700:4700::1111", "2606:4700:4700::1001"], + "TXT": [ + '"v=spf1 ip4:103.151.192.0/23 ip4:185.12.80.0/22 ip4:188.172.128.0/20 ip4:192.161.144.0/20 ip4:216.198.0.0/18 ~all"' + ], + }, + "1.1.1.1.in-addr.arpa": {"PTR": ["one.one.one.one."]}, +} + @pytest.mark.asyncio -async def test_dns(bbot_scanner, bbot_config, mock_dns): - scan = bbot_scanner("1.1.1.1", config=bbot_config) - helpers = scan.helpers +async def test_dns_engine(bbot_scanner): + scan = bbot_scanner() + 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"]}} + ) + result = await scan.helpers.resolve("one.one.one.one") + assert "1.1.1.1" in result + assert not "2606:4700:4700::1111" in result + + results = [_ async for _ in scan.helpers.resolve_batch(("one.one.one.one", "1.1.1.1"))] + pass_1 = False + pass_2 = False + for query, result in results: + if query == "one.one.one.one" and "1.1.1.1" in result: + pass_1 = True + elif query == "1.1.1.1" and "one.one.one.one" in result: + pass_2 = True + assert pass_1 and pass_2 + + results = [_ async for _ in scan.helpers.resolve_raw_batch((("one.one.one.one", "A"), ("1.1.1.1", "PTR")))] + pass_1 = False + pass_2 = False + for (query, rdtype), (result, errors) in results: + result = extract_targets(result) + _results = [r[1] for r in result] + if query == "one.one.one.one" and "1.1.1.1" in _results: + pass_1 = True + elif query == "1.1.1.1" and "one.one.one.one" in _results: + pass_2 = True + assert pass_1 and pass_2 + + from bbot.core.helpers.dns.mock import MockResolver + + # ensure dns records are being properly cleaned + mockresolver = MockResolver({"evilcorp.com": {"MX": ["0 ."]}}) + mx_records = await mockresolver.resolve("evilcorp.com", rdtype="MX") + results = set() + for r in mx_records: + results.update(extract_targets(r)) + assert not results + + +@pytest.mark.asyncio +async def test_dns_resolution(bbot_scanner): + scan = bbot_scanner("1.1.1.1") + + from bbot.core.helpers.dns.engine import DNSEngine + + dnsengine = DNSEngine(None) + await dnsengine._mock_dns(mock_records) # lowest level functions - a_responses = await helpers._resolve_hostname("one.one.one.one") - aaaa_responses = await helpers._resolve_hostname("one.one.one.one", rdtype="AAAA") - ip_responses = await helpers._resolve_ip("1.1.1.1") + a_responses = await dnsengine._resolve_hostname("one.one.one.one") + aaaa_responses = await dnsengine._resolve_hostname("one.one.one.one", rdtype="AAAA") + ip_responses = await dnsengine._resolve_ip("1.1.1.1") assert a_responses[0].response.answer[0][0].address in ("1.1.1.1", "1.0.0.1") assert aaaa_responses[0].response.answer[0][0].address in ("2606:4700:4700::1111", "2606:4700:4700::1001") assert ip_responses[0].response.answer[0][0].target.to_text() in ("one.one.one.one.",) # mid level functions - _responses, errors = await helpers.resolve_raw("one.one.one.one") + answers, errors = await dnsengine.resolve_raw("one.one.one.one", type="A") responses = [] - for rdtype, response in _responses: - for answers in response: - responses += list(helpers.extract_targets(answers)) + for answer in answers: + responses += list(extract_targets(answer)) assert ("A", "1.1.1.1") in responses - _responses, errors = await helpers.resolve_raw("one.one.one.one", rdtype="AAAA") + assert not ("AAAA", "2606:4700:4700::1111") in responses + answers, errors = await dnsengine.resolve_raw("one.one.one.one", type="AAAA") responses = [] - for rdtype, response in _responses: - for answers in response: - responses += list(helpers.extract_targets(answers)) + for answer in answers: + responses += list(extract_targets(answer)) + assert not ("A", "1.1.1.1") in responses assert ("AAAA", "2606:4700:4700::1111") in responses - _responses, errors = await helpers.resolve_raw("1.1.1.1") + answers, errors = await dnsengine.resolve_raw("1.1.1.1") responses = [] - for rdtype, response in _responses: - for answers in response: - responses += list(helpers.extract_targets(answers)) + for answer in answers: + responses += list(extract_targets(answer)) assert ("PTR", "one.one.one.one") in responses # high level functions - assert "1.1.1.1" in await helpers.resolve("one.one.one.one") - assert "2606:4700:4700::1111" in await helpers.resolve("one.one.one.one", type="AAAA") - assert "one.one.one.one" in await helpers.resolve("1.1.1.1") + dnsengine = DNSEngine(None) + assert "1.1.1.1" in await dnsengine.resolve("one.one.one.one") + assert "2606:4700:4700::1111" in await dnsengine.resolve("one.one.one.one", type="AAAA") + assert "one.one.one.one" in await dnsengine.resolve("1.1.1.1") for rdtype in ("NS", "SOA", "MX", "TXT"): - assert len(await helpers.resolve("google.com", type=rdtype)) > 0 + assert len(await dnsengine.resolve("google.com", type=rdtype)) > 0 # batch resolution - batch_results = [r async for r in helpers.resolve_batch(["1.1.1.1", "one.one.one.one"])] + batch_results = [r async for r in dnsengine.resolve_batch(["1.1.1.1", "one.one.one.one"])] assert len(batch_results) == 2 batch_results = dict(batch_results) assert any([x in batch_results["one.one.one.one"] for x in ("1.1.1.1", "1.0.0.1")]) assert "one.one.one.one" in batch_results["1.1.1.1"] - # "any" type - resolved = await helpers.resolve("google.com", type="any") - assert any([helpers.is_subdomain(h) for h in resolved]) + # custom batch resolution + batch_results = [r async for r in dnsengine.resolve_raw_batch([("1.1.1.1", "PTR"), ("one.one.one.one", "A")])] + batch_results = [(response.to_text(), response.rdtype.name) for query, (response, errors) in batch_results] + assert len(batch_results) == 3 + assert any(answer == "1.0.0.1" and rdtype == "A" for answer, rdtype in batch_results) + assert any(answer == "one.one.one.one." and rdtype == "PTR" for answer, rdtype in batch_results) # dns cache - helpers.dns._dns_cache.clear() - assert hash(f"1.1.1.1:PTR") not in helpers.dns._dns_cache - assert hash(f"one.one.one.one:A") not in helpers.dns._dns_cache - assert hash(f"one.one.one.one:AAAA") not in helpers.dns._dns_cache - await helpers.resolve("1.1.1.1", use_cache=False) - await helpers.resolve("one.one.one.one", use_cache=False) - assert hash(f"1.1.1.1:PTR") not in helpers.dns._dns_cache - assert hash(f"one.one.one.one:A") not in helpers.dns._dns_cache - assert hash(f"one.one.one.one:AAAA") not in helpers.dns._dns_cache - - await helpers.resolve("1.1.1.1") - assert hash(f"1.1.1.1:PTR") in helpers.dns._dns_cache - await helpers.resolve("one.one.one.one") - assert hash(f"one.one.one.one:A") in helpers.dns._dns_cache - assert hash(f"one.one.one.one:AAAA") in helpers.dns._dns_cache + dnsengine._dns_cache.clear() + assert hash(f"1.1.1.1:PTR") not in dnsengine._dns_cache + assert hash(f"one.one.one.one:A") not in dnsengine._dns_cache + assert hash(f"one.one.one.one:AAAA") not in dnsengine._dns_cache + await dnsengine.resolve("1.1.1.1", use_cache=False) + await dnsengine.resolve("one.one.one.one", use_cache=False) + assert hash(f"1.1.1.1:PTR") not in dnsengine._dns_cache + assert hash(f"one.one.one.one:A") not in dnsengine._dns_cache + assert hash(f"one.one.one.one:AAAA") not in dnsengine._dns_cache + + await dnsengine.resolve("1.1.1.1") + assert hash(f"1.1.1.1:PTR") in dnsengine._dns_cache + await dnsengine.resolve("one.one.one.one", type="A") + assert hash(f"one.one.one.one:A") in dnsengine._dns_cache + assert not hash(f"one.one.one.one:AAAA") in dnsengine._dns_cache + dnsengine._dns_cache.clear() + await dnsengine.resolve("one.one.one.one", type="AAAA") + assert hash(f"one.one.one.one:AAAA") in dnsengine._dns_cache + assert not hash(f"one.one.one.one:A") in dnsengine._dns_cache # Ensure events with hosts have resolved_hosts attribute populated - resolved_hosts_event1 = scan.make_event("one.one.one.one", "DNS_NAME", dummy=True) - resolved_hosts_event2 = scan.make_event("http://one.one.one.one/", "URL_UNVERIFIED", dummy=True) - event_tags1, event_whitelisted1, event_blacklisted1, children1 = await scan.helpers.resolve_event( - resolved_hosts_event1 - ) - event_tags2, event_whitelisted2, event_blacklisted2, children2 = await scan.helpers.resolve_event( - resolved_hosts_event2 - ) - assert "1.1.1.1" in [str(x) for x in children1["A"]] - assert "1.1.1.1" in [str(x) for x in children2["A"]] - assert set(children1.keys()) == set(children2.keys()) - - dns_config = OmegaConf.create({"dns_resolution": True}) - dns_config = OmegaConf.merge(bbot_config, dns_config) - scan2 = bbot_scanner("evilcorp.com", config=dns_config) - mock_dns( - scan2, + await scan._prep() + resolved_hosts_event1 = scan.make_event("one.one.one.one", "DNS_NAME", parent=scan.root_event) + resolved_hosts_event2 = scan.make_event("http://one.one.one.one/", "URL_UNVERIFIED", parent=scan.root_event) + dnsresolve = scan.modules["dnsresolve"] + assert hash(resolved_hosts_event1.host) not in dnsresolve._event_cache + assert hash(resolved_hosts_event2.host) not in dnsresolve._event_cache + await dnsresolve.handle_event(resolved_hosts_event1, {}) + assert hash(resolved_hosts_event1.host) in dnsresolve._event_cache + assert hash(resolved_hosts_event2.host) in dnsresolve._event_cache + await dnsresolve.handle_event(resolved_hosts_event2, {}) + assert "1.1.1.1" in resolved_hosts_event2.resolved_hosts + assert "1.1.1.1" in resolved_hosts_event2.dns_children["A"] + assert resolved_hosts_event1.resolved_hosts == resolved_hosts_event2.resolved_hosts + assert resolved_hosts_event1.dns_children == resolved_hosts_event2.dns_children + assert "a-record" in resolved_hosts_event1.tags + assert not "a-record" in resolved_hosts_event2.tags + + scan2 = bbot_scanner("evilcorp.com", config={"dns": {"minimal": False}}) + await scan2.helpers.dns._mock_dns( { "evilcorp.com": {"TXT": ['"v=spf1 include:cloudprovider.com ~all"']}, "cloudprovider.com": {"A": ["1.2.3.4"]}, @@ -99,48 +169,51 @@ async def test_dns(bbot_scanner, bbot_config, mock_dns): @pytest.mark.asyncio -async def test_wildcards(bbot_scanner, bbot_config): - scan = bbot_scanner("1.1.1.1", config=bbot_config) +async def test_wildcards(bbot_scanner): + scan = bbot_scanner("1.1.1.1") helpers = scan.helpers + from bbot.core.helpers.dns.engine import DNSEngine + + dnsengine = DNSEngine(None) + # wildcards - wildcard_domains = await helpers.is_wildcard_domain("asdf.github.io") - assert hash("github.io") in helpers.dns._wildcard_cache - assert hash("asdf.github.io") in helpers.dns._wildcard_cache + wildcard_domains = await dnsengine.is_wildcard_domain("asdf.github.io") + assert hash("github.io") in dnsengine._wildcard_cache + assert hash("asdf.github.io") in dnsengine._wildcard_cache assert "github.io" in wildcard_domains assert "A" in wildcard_domains["github.io"] assert "SRV" not in wildcard_domains["github.io"] assert wildcard_domains["github.io"]["A"] and all(helpers.is_ip(r) for r in wildcard_domains["github.io"]["A"]) - helpers.dns._wildcard_cache.clear() + dnsengine._wildcard_cache.clear() - wildcard_rdtypes = await helpers.is_wildcard("blacklanternsecurity.github.io") + wildcard_rdtypes = await dnsengine.is_wildcard("blacklanternsecurity.github.io") assert "A" in wildcard_rdtypes assert "SRV" not in wildcard_rdtypes assert wildcard_rdtypes["A"] == (True, "github.io") - assert hash("github.io") in helpers.dns._wildcard_cache - assert len(helpers.dns._wildcard_cache[hash("github.io")]) > 0 - helpers.dns._wildcard_cache.clear() + assert hash("github.io") in dnsengine._wildcard_cache + assert len(dnsengine._wildcard_cache[hash("github.io")]) > 0 + dnsengine._wildcard_cache.clear() - wildcard_rdtypes = await helpers.is_wildcard("asdf.asdf.asdf.github.io") + wildcard_rdtypes = await dnsengine.is_wildcard("asdf.asdf.asdf.github.io") assert "A" in wildcard_rdtypes assert "SRV" not in wildcard_rdtypes assert wildcard_rdtypes["A"] == (True, "github.io") - assert hash("github.io") in helpers.dns._wildcard_cache - assert not hash("asdf.github.io") in helpers.dns._wildcard_cache - assert not hash("asdf.asdf.github.io") in helpers.dns._wildcard_cache - assert not hash("asdf.asdf.asdf.github.io") in helpers.dns._wildcard_cache - assert len(helpers.dns._wildcard_cache[hash("github.io")]) > 0 + assert hash("github.io") in dnsengine._wildcard_cache + assert not hash("asdf.github.io") in dnsengine._wildcard_cache + assert not hash("asdf.asdf.github.io") in dnsengine._wildcard_cache + assert not hash("asdf.asdf.asdf.github.io") in dnsengine._wildcard_cache + assert len(dnsengine._wildcard_cache[hash("github.io")]) > 0 wildcard_event1 = scan.make_event("wat.asdf.fdsa.github.io", "DNS_NAME", dummy=True) wildcard_event2 = scan.make_event("wats.asd.fdsa.github.io", "DNS_NAME", dummy=True) wildcard_event3 = scan.make_event("github.io", "DNS_NAME", dummy=True) # event resolution - event_tags1, event_whitelisted1, event_blacklisted1, children1 = await scan.helpers.resolve_event(wildcard_event1) - event_tags2, event_whitelisted2, event_blacklisted2, children2 = await scan.helpers.resolve_event(wildcard_event2) - event_tags3, event_whitelisted3, event_blacklisted3, children3 = await scan.helpers.resolve_event(wildcard_event3) - await helpers.handle_wildcard_event(wildcard_event1, children1) - await helpers.handle_wildcard_event(wildcard_event2, children2) - await helpers.handle_wildcard_event(wildcard_event3, children3) + await scan._prep() + dnsresolve = scan.modules["dnsresolve"] + await dnsresolve.handle_event(wildcard_event1, {}) + await dnsresolve.handle_event(wildcard_event2, {}) + await dnsresolve.handle_event(wildcard_event3, {}) assert "wildcard" in wildcard_event1.tags assert "a-wildcard" in wildcard_event1.tags assert "srv-wildcard" not in wildcard_event1.tags @@ -149,6 +222,195 @@ async def test_wildcards(bbot_scanner, bbot_config): assert "srv-wildcard" not in wildcard_event2.tags assert wildcard_event1.data == "_wildcard.github.io" assert wildcard_event2.data == "_wildcard.github.io" - assert "wildcard-domain" in wildcard_event3.tags - assert "a-wildcard-domain" in wildcard_event3.tags - assert "srv-wildcard-domain" not in wildcard_event3.tags + assert wildcard_event3.data == "github.io" + + # dns resolve distance + event_distance_0 = scan.make_event("8.8.8.8", module=scan._make_dummy_module_dns("PTR"), parent=scan.root_event) + assert event_distance_0.dns_resolve_distance == 0 + event_distance_1 = scan.make_event( + "evilcorp.com", module=scan._make_dummy_module_dns("A"), parent=event_distance_0 + ) + assert event_distance_1.dns_resolve_distance == 1 + event_distance_2 = scan.make_event("1.2.3.4", module=scan._make_dummy_module_dns("PTR"), parent=event_distance_1) + assert event_distance_2.dns_resolve_distance == 1 + event_distance_3 = scan.make_event( + "evilcorp.org", module=scan._make_dummy_module_dns("A"), parent=event_distance_2 + ) + assert event_distance_3.dns_resolve_distance == 2 + + from bbot.scanner import Scanner + + # test with full scan + scan2 = Scanner("asdfl.gashdgkjsadgsdf.github.io", whitelist=["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) == 3 + assert 1 == len([e for e in events if e.type == "SCAN"]) + unmodified_wildcard_events = [ + e for e in events if e.type == "DNS_NAME" and e.data == "asdfl.gashdgkjsadgsdf.github.io" + ] + assert len(unmodified_wildcard_events) == 1 + assert unmodified_wildcard_events[0].tags.issuperset( + { + "a-record", + "target", + "aaaa-wildcard", + "in-scope", + "subdomain", + "aaaa-record", + "wildcard", + "a-wildcard", + } + ) + modified_wildcard_events = [e for e in events if e.type == "DNS_NAME" and e.data == "_wildcard.github.io"] + assert len(modified_wildcard_events) == 1 + assert modified_wildcard_events[0].tags.issuperset( + { + "a-record", + "aaaa-wildcard", + "in-scope", + "subdomain", + "aaaa-record", + "wildcard", + "a-wildcard", + } + ) + assert modified_wildcard_events[0].host_original == "lkjg.sdfgsg.jgkhajshdsadf.github.io" + + # test with full scan (wildcard detection disabled for domain) + scan2 = Scanner( + "asdfl.gashdgkjsadgsdf.github.io", + whitelist=["github.io"], + config={"dns": {"wildcard_ignore": ["github.io"]}}, + exclude_modules=["cloudcheck"], + ) + 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) == 3 + assert 1 == len([e for e in events if e.type == "SCAN"]) + unmodified_wildcard_events = [e for e in events if e.type == "DNS_NAME" and "_wildcard" not in e.data] + assert len(unmodified_wildcard_events) == 2 + assert 1 == len( + [ + e + for e in unmodified_wildcard_events + if e.data == "asdfl.gashdgkjsadgsdf.github.io" + and e.tags.issuperset( + { + "target", + "a-record", + "in-scope", + "subdomain", + "aaaa-record", + } + ) + ] + ) + assert 1 == len( + [ + e + for e in unmodified_wildcard_events + if e.data == "lkjg.sdfgsg.jgkhajshdsadf.github.io" + and e.tags.issuperset( + { + "a-record", + "in-scope", + "subdomain", + "aaaa-record", + } + ) + ] + ) + modified_wildcard_events = [e for e in events if e.type == "DNS_NAME" and e.data == "_wildcard.github.io"] + assert len(modified_wildcard_events) == 0 + + +@pytest.mark.asyncio +async def test_dns_raw_records(bbot_scanner): + + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + watched_events = ["*"] + + async def setup(self): + self.events = [] + return True + + async def handle_event(self, event): + self.events.append(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.helpers.dns._mock_dns(mock_records) + dummy_module = DummyModule(scan) + 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"]) + assert 1 == len( + [ + e + for e in events + if e.type == "RAW_DNS_RECORD" + and e.host == "one.one.one.one" + and e.data["host"] == "one.one.one.one" + and e.data["type"] == "TXT" + and e.data["answer"] + == '"v=spf1 ip4:103.151.192.0/23 ip4:185.12.80.0/22 ip4:188.172.128.0/20 ip4:192.161.144.0/20 ip4:216.198.0.0/18 ~all"' + and e.discovery_context == "TXT lookup on one.one.one.one produced RAW_DNS_RECORD" + ] + ) + assert 1 == len( + [ + e + for e in dummy_module.events + if e.type == "RAW_DNS_RECORD" + and e.host == "one.one.one.one" + and e.data["host"] == "one.one.one.one" + and e.data["type"] == "TXT" + and e.data["answer"] + == '"v=spf1 ip4:103.151.192.0/23 ip4:185.12.80.0/22 ip4:188.172.128.0/20 ip4:192.161.144.0/20 ip4:216.198.0.0/18 ~all"' + and e.discovery_context == "TXT lookup on one.one.one.one produced RAW_DNS_RECORD" + ] + ) + # scan with omitted event type + scan = bbot_scanner("one.one.one.one", config={"dns": {"minimal": False}, "omit_event_types": ["RAW_DNS_RECORD"]}) + await scan.helpers.dns._mock_dns(mock_records) + dummy_module = DummyModule(scan) + scan.modules["dummy_module"] = dummy_module + events = [e async for e in scan.async_start()] + # no raw records should be emitted + assert 0 == len([e for e in events if e.type == "RAW_DNS_RECORD"]) + assert 0 == len([e for e in dummy_module.events if e.type == "RAW_DNS_RECORD"]) + + # 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.helpers.dns._mock_dns(mock_records) + dummy_module = DummyModule(scan) + scan.modules["dummy_module"] = dummy_module + events = [e async for e in scan.async_start()] + # no raw records should be ouptut + assert 0 == len([e for e in events if e.type == "RAW_DNS_RECORD"]) + # but they should still make it to the module + assert 1 == len( + [ + e + for e in dummy_module.events + if e.type == "RAW_DNS_RECORD" + and e.host == "one.one.one.one" + and e.data["host"] == "one.one.one.one" + and e.data["type"] == "TXT" + and e.data["answer"] + == '"v=spf1 ip4:103.151.192.0/23 ip4:185.12.80.0/22 ip4:188.172.128.0/20 ip4:192.161.144.0/20 ip4:216.198.0.0/18 ~all"' + and e.discovery_context == "TXT lookup on one.one.one.one produced RAW_DNS_RECORD" + ] + ) diff --git a/bbot/test/test_step_1/test_docs.py b/bbot/test/test_step_1/test_docs.py index 6b00d2d0d..a86947ff0 100644 --- a/bbot/test/test_step_1/test_docs.py +++ b/bbot/test/test_step_1/test_docs.py @@ -1,5 +1,4 @@ -from bbot.scripts.docs import update_docs - - def test_docs(): + from bbot.scripts.docs import update_docs + update_docs() diff --git a/bbot/test/test_step_1/test_engine.py b/bbot/test/test_step_1/test_engine.py new file mode 100644 index 000000000..a8a4156d1 --- /dev/null +++ b/bbot/test/test_step_1/test_engine.py @@ -0,0 +1,146 @@ +from ..bbot_fixtures import * + + +@pytest.mark.asyncio +async def test_engine(): + from bbot.core.engine import EngineClient, EngineServer + + counter = 0 + yield_cancelled = False + yield_errored = False + return_started = False + return_finished = False + return_cancelled = False + return_errored = False + + class TestEngineServer(EngineServer): + + CMDS = { + 0: "return_thing", + 1: "yield_stuff", + } + + async def return_thing(self, n): + nonlocal return_started + nonlocal return_finished + nonlocal return_cancelled + nonlocal return_errored + try: + return_started = True + await asyncio.sleep(n) + return_finished = True + return f"thing{n}" + except asyncio.CancelledError: + return_cancelled = True + raise + except Exception: + return_errored = True + raise + + async def yield_stuff(self, n): + nonlocal counter + nonlocal yield_cancelled + nonlocal yield_errored + try: + for i in range(n): + yield f"thing{i}" + counter += 1 + await asyncio.sleep(0.1) + except asyncio.CancelledError: + yield_cancelled = True + raise + except Exception: + yield_errored = True + raise + + class TestEngineClient(EngineClient): + + SERVER_CLASS = TestEngineServer + + async def return_thing(self, n): + return await self.run_and_return("return_thing", n) + + async def yield_stuff(self, n): + async for _ in self.run_and_yield("yield_stuff", n): + yield _ + + test_engine = TestEngineClient() + + # test return functionality + return_res = await test_engine.return_thing(1) + assert return_res == "thing1" + + # test async generator + assert counter == 0 + assert yield_cancelled == False + yield_res = [r async for r in test_engine.yield_stuff(13)] + assert yield_res == [f"thing{i}" for i in range(13)] + assert len(yield_res) == 13 + assert counter == 13 + + # test async generator with cancellation + counter = 0 + yield_cancelled = False + yield_errored = False + agen = test_engine.yield_stuff(1000) + async for r in agen: + if counter > 10: + await agen.aclose() + break + await asyncio.sleep(5) + assert yield_cancelled == True + assert yield_errored == False + assert counter < 15 + + # test async generator with error + yield_cancelled = False + yield_errored = False + agen = test_engine.yield_stuff(None) + with pytest.raises(BBOTEngineError): + async for _ in agen: + pass + assert yield_cancelled == False + assert yield_errored == True + + # test return with cancellation + return_started = False + return_finished = False + return_cancelled = False + return_errored = False + task = asyncio.create_task(test_engine.return_thing(2)) + await asyncio.sleep(1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0.1) + assert return_started == True + assert return_finished == False + assert return_cancelled == True + assert return_errored == False + + # test return with late cancellation + return_started = False + return_finished = False + return_cancelled = False + return_errored = False + task = asyncio.create_task(test_engine.return_thing(1)) + await asyncio.sleep(2) + task.cancel() + result = await task + assert result == "thing1" + assert return_started == True + assert return_finished == True + assert return_cancelled == False + assert return_errored == False + + # test return with error + return_started = False + return_finished = False + return_cancelled = False + return_errored = False + with pytest.raises(BBOTEngineError): + result = await test_engine.return_thing(None) + assert return_started == True + assert return_finished == False + assert return_cancelled == False + assert return_errored == True diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index b6846d6e2..8b7503d13 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -3,10 +3,15 @@ import ipaddress from ..bbot_fixtures import * +from bbot.scanner import Scanner @pytest.mark.asyncio -async def test_events(events, scan, helpers, bbot_config): +async def test_events(events, helpers): + + scan = Scanner() + await scan._prep() + assert events.ipv4.type == "IP_ADDRESS" assert events.ipv6.type == "IP_ADDRESS" assert events.netv4.type == "IP_RANGE" @@ -100,7 +105,7 @@ async def test_events(events, scan, helpers, bbot_config): # http response assert events.http_response.host == "example.com" assert events.http_response.port == 80 - assert events.http_response.parsed.scheme == "http" + assert events.http_response.parsed_url.scheme == "http" assert events.http_response.with_port().geturl() == "http://example.com:80/" http_response = scan.make_event( @@ -159,8 +164,9 @@ async def test_events(events, scan, helpers, bbot_config): assert events.ipv6_url_unverified.host == ipaddress.ip_address("2001:4860:4860::8888") assert events.ipv6_url_unverified.port == 443 - javascript_event = scan.make_event("http://evilcorp.com/asdf/a.js?b=c#d", "URL_UNVERIFIED", dummy=True) + javascript_event = scan.make_event("http://evilcorp.com/asdf/a.js?b=c#d", "URL_UNVERIFIED", parent=scan.root_event) assert "extension-js" in javascript_event.tags + await scan.ingress_module.handle_event(javascript_event, {}) assert "httpx-only" in javascript_event.tags # scope distance @@ -168,70 +174,76 @@ async def test_events(events, scan, helpers, bbot_config): assert event1._scope_distance == -1 event1.scope_distance = 0 assert event1._scope_distance == 0 - event2 = scan.make_event("2.3.4.5", source=event1) + event2 = scan.make_event("2.3.4.5", parent=event1) assert event2._scope_distance == 1 - event3 = scan.make_event("3.4.5.6", source=event2) + event3 = scan.make_event("3.4.5.6", parent=event2) assert event3._scope_distance == 2 - event4 = scan.make_event("3.4.5.6", source=event3) + event4 = scan.make_event("3.4.5.6", parent=event3) assert event4._scope_distance == 2 - event5 = scan.make_event("4.5.6.7", source=event4) + event5 = scan.make_event("4.5.6.7", parent=event4) assert event5._scope_distance == 3 - url_1 = scan.make_event("https://127.0.0.1/asdf", "URL_UNVERIFIED", source=scan.root_event) + url_1 = scan.make_event("https://127.0.0.1/asdf", "URL_UNVERIFIED", parent=scan.root_event) assert url_1.scope_distance == 1 - url_2 = scan.make_event("https://127.0.0.1/test", "URL_UNVERIFIED", source=url_1) + url_2 = scan.make_event("https://127.0.0.1/test", "URL_UNVERIFIED", parent=url_1) assert url_2.scope_distance == 1 - url_3 = scan.make_event("https://127.0.0.2/asdf", "URL_UNVERIFIED", source=url_1) + url_3 = scan.make_event("https://127.0.0.2/asdf", "URL_UNVERIFIED", parent=url_1) assert url_3.scope_distance == 2 - org_stub_1 = scan.make_event("STUB1", "ORG_STUB", source=scan.root_event) + org_stub_1 = scan.make_event("STUB1", "ORG_STUB", parent=scan.root_event) org_stub_1.scope_distance == 1 - org_stub_2 = scan.make_event("STUB2", "ORG_STUB", source=org_stub_1) + org_stub_2 = scan.make_event("STUB2", "ORG_STUB", parent=org_stub_1) org_stub_2.scope_distance == 2 # internal event tracking root_event = scan.make_event("0.0.0.0", dummy=True) - internal_event1 = scan.make_event("1.2.3.4", source=root_event, internal=True) + internal_event1 = scan.make_event("1.2.3.4", parent=root_event, internal=True) assert internal_event1._internal == True assert "internal" in internal_event1.tags # tag inheritance for tag in ("affiliate", "mutation-1"): - affiliate_event = scan.make_event("1.2.3.4", source=root_event, tags=tag) + affiliate_event = scan.make_event("1.2.3.4", parent=root_event, tags=tag) assert tag in affiliate_event.tags - affiliate_event2 = scan.make_event("1.2.3.4:88", source=affiliate_event) - affiliate_event3 = scan.make_event("4.3.2.1:88", source=affiliate_event) + affiliate_event2 = scan.make_event("1.2.3.4:88", parent=affiliate_event) + affiliate_event3 = scan.make_event("4.3.2.1:88", parent=affiliate_event) assert tag in affiliate_event2.tags assert tag not in affiliate_event3.tags + # discovery context + event = scan.make_event( + "127.0.0.1", parent=scan.root_event, context="something discovered {event.type}: {event.data}" + ) + assert event.discovery_context == "something discovered IP_ADDRESS: 127.0.0.1" + # updating an already-created event with make_event() # updating tags - event1 = scan.make_event("127.0.0.1", source=scan.root_event) + event1 = scan.make_event("127.0.0.1", parent=scan.root_event) updated_event = scan.make_event(event1, tags="asdf") assert "asdf" not in event1.tags assert "asdf" in updated_event.tags - # updating source - event2 = scan.make_event("127.0.0.1", source=scan.root_event) - updated_event = scan.make_event(event2, source=event1) - assert event2.source == scan.root_event - assert updated_event.source == event1 + # updating parent + event2 = scan.make_event("127.0.0.1", parent=scan.root_event) + updated_event = scan.make_event(event2, parent=event1) + assert event2.parent == scan.root_event + assert updated_event.parent == event1 # updating module - event3 = scan.make_event("127.0.0.1", source=scan.root_event) + event3 = scan.make_event("127.0.0.1", parent=scan.root_event) updated_event = scan.make_event(event3, internal=True) assert event3.internal == False assert updated_event.internal == True # event sorting - parent1 = scan.make_event("127.0.0.1", source=scan.root_event) - parent2 = scan.make_event("127.0.0.1", source=scan.root_event) - parent2_child1 = scan.make_event("127.0.0.1", source=parent2) - parent1_child1 = scan.make_event("127.0.0.1", source=parent1) - parent1_child2 = scan.make_event("127.0.0.1", source=parent1) - parent1_child2_child1 = scan.make_event("127.0.0.1", source=parent1_child2) - parent1_child2_child2 = scan.make_event("127.0.0.1", source=parent1_child2) - parent1_child1_child1 = scan.make_event("127.0.0.1", source=parent1_child1) - parent2_child2 = scan.make_event("127.0.0.1", source=parent2) - parent1_child2_child1_child1 = scan.make_event("127.0.0.1", source=parent1_child2_child1) + parent1 = scan.make_event("127.0.0.1", parent=scan.root_event) + parent2 = scan.make_event("127.0.0.1", parent=scan.root_event) + parent2_child1 = scan.make_event("127.0.0.1", parent=parent2) + parent1_child1 = scan.make_event("127.0.0.1", parent=parent1) + parent1_child2 = scan.make_event("127.0.0.1", parent=parent1) + parent1_child2_child1 = scan.make_event("127.0.0.1", parent=parent1_child2) + parent1_child2_child2 = scan.make_event("127.0.0.1", parent=parent1_child2) + parent1_child1_child1 = scan.make_event("127.0.0.1", parent=parent1_child1) + parent2_child2 = scan.make_event("127.0.0.1", parent=parent2) + parent1_child2_child1_child1 = scan.make_event("127.0.0.1", parent=parent1_child2_child1) sortable_events = { "parent1": parent1, @@ -392,64 +404,271 @@ async def test_events(events, scan, helpers, bbot_config): # test event serialization from bbot.core.event import event_from_json - db_event = scan.make_event("evilcorp.com", dummy=True) + db_event = scan.make_event("evilcorp.com:80", parent=scan.root_event, context="test context") db_event._resolved_hosts = {"127.0.0.1"} db_event.scope_distance = 1 + assert db_event.discovery_context == "test context" + assert db_event.discovery_path == ["test context"] timestamp = db_event.timestamp.timestamp() json_event = db_event.json() assert json_event["scope_distance"] == 1 - assert json_event["data"] == "evilcorp.com" - assert json_event["type"] == "DNS_NAME" + assert json_event["data"] == "evilcorp.com:80" + assert json_event["type"] == "OPEN_TCP_PORT" + assert json_event["host"] == "evilcorp.com" assert json_event["timestamp"] == timestamp + assert json_event["discovery_context"] == "test context" + assert json_event["discovery_path"] == ["test context"] reconstituted_event = event_from_json(json_event) assert reconstituted_event.scope_distance == 1 assert reconstituted_event.timestamp.timestamp() == timestamp - assert reconstituted_event.data == "evilcorp.com" - assert reconstituted_event.type == "DNS_NAME" + assert reconstituted_event.data == "evilcorp.com:80" + assert reconstituted_event.type == "OPEN_TCP_PORT" + assert reconstituted_event.host == "evilcorp.com" + assert reconstituted_event.discovery_context == "test context" + assert reconstituted_event.discovery_path == ["test context"] assert "127.0.0.1" in reconstituted_event.resolved_hosts + hostless_event = scan.make_event("asdf", "ASDF", dummy=True) + hostless_event_json = hostless_event.json() + assert hostless_event_json["type"] == "ASDF" + assert hostless_event_json["data"] == "asdf" + assert not "host" 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"] == {"DNS_NAME": "evilcorp.com"} - assert json_event_siemfriendly["type"] == "DNS_NAME" + 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.timestamp() == timestamp - assert reconstituted_event2.data == "evilcorp.com" - assert reconstituted_event2.type == "DNS_NAME" + 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", source=scan.root_event) - assert http_response.source_id == scan.root_event.id + 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" json_event = http_response.json(mode="graph") assert isinstance(json_event["data"], str) json_event = http_response.json() assert isinstance(json_event["data"], dict) assert json_event["type"] == "HTTP_RESPONSE" - assert json_event["source"] == scan.root_event.id + assert json_event["host"] == "example.com" + assert json_event["parent"] == scan.root_event.id reconstituted_event = event_from_json(json_event) assert isinstance(reconstituted_event.data, dict) assert reconstituted_event.data["input"] == "http://example.com:80" + assert reconstituted_event.host == "example.com" assert reconstituted_event.type == "HTTP_RESPONSE" - assert reconstituted_event.source_id == scan.root_event.id + assert reconstituted_event.parent_id == scan.root_event.id - event_1 = scan.make_event("127.0.0.1", source=scan.root_event) - event_2 = scan.make_event("127.0.0.2", source=event_1) - event_3 = scan.make_event("127.0.0.3", source=event_2) + event_1 = scan.make_event("127.0.0.1", parent=scan.root_event) + event_2 = scan.make_event("127.0.0.2", parent=event_1) + event_3 = scan.make_event("127.0.0.3", parent=event_2) event_3._omit = True - event_4 = scan.make_event("127.0.0.4", source=event_3) - event_5 = scan.make_event("127.0.0.5", source=event_4) - assert event_5.get_sources() == [event_4, event_3, event_2, event_1, scan.root_event] - assert event_5.get_sources(omit=True) == [event_4, event_2, event_1, scan.root_event] + event_4 = scan.make_event("127.0.0.4", parent=event_3) + event_5 = scan.make_event("127.0.0.5", parent=event_4) + assert event_5.get_parents() == [event_4, event_3, event_2, event_1, scan.root_event] + assert event_5.get_parents(omit=True) == [event_4, event_2, event_1, scan.root_event] + + # test host backup + host_event = scan.make_event("asdf.evilcorp.com", "DNS_NAME", parent=scan.root_event) + assert host_event.host_original == "asdf.evilcorp.com" + host_event.host = "_wildcard.evilcorp.com" + assert host_event.host == "_wildcard.evilcorp.com" + assert host_event.host_original == "asdf.evilcorp.com" # test storage bucket validation bucket_event = scan.make_event( {"name": "ASDF.s3.amazonaws.com", "url": "https://ASDF.s3.amazonaws.com"}, "STORAGE_BUCKET", - source=scan.root_event, + parent=scan.root_event, ) assert bucket_event.data["name"] == "asdf.s3.amazonaws.com" assert bucket_event.data["url"] == "https://asdf.s3.amazonaws.com/" + + # test module sequence + module = scan._make_dummy_module("mymodule") + parent_event_1 = scan.make_event("127.0.0.1", module=module, parent=scan.root_event) + assert str(parent_event_1.module) == "mymodule" + assert str(parent_event_1.module_sequence) == "mymodule" + parent_event_2 = scan.make_event("127.0.0.2", module=module, parent=parent_event_1) + assert str(parent_event_2.module) == "mymodule" + assert str(parent_event_2.module_sequence) == "mymodule" + parent_event_3 = scan.make_event("127.0.0.3", module=module, parent=parent_event_2) + assert str(parent_event_3.module) == "mymodule" + assert str(parent_event_3.module_sequence) == "mymodule" + + module = scan._make_dummy_module("mymodule") + parent_event_1 = scan.make_event("127.0.0.1", module=module, parent=scan.root_event) + parent_event_1._omit = True + assert str(parent_event_1.module) == "mymodule" + assert str(parent_event_1.module_sequence) == "mymodule" + parent_event_2 = scan.make_event("127.0.0.2", module=module, parent=parent_event_1) + parent_event_2._omit = True + assert str(parent_event_2.module) == "mymodule" + assert str(parent_event_2.module_sequence) == "mymodule->mymodule" + parent_event_3 = scan.make_event("127.0.0.3", module=module, parent=parent_event_2) + assert str(parent_event_3.module) == "mymodule" + assert str(parent_event_3.module_sequence) == "mymodule->mymodule->mymodule" + + +@pytest.mark.asyncio +async def test_event_discovery_context(): + + from bbot.modules.base import BaseModule + + scan = Scanner("evilcorp.com") + await scan.helpers.dns._mock_dns( + { + "evilcorp.com": {"A": ["1.2.3.4"]}, + "one.evilcorp.com": {"A": ["1.2.3.4"]}, + "two.evilcorp.com": {"A": ["1.2.3.4"]}, + "three.evilcorp.com": {"A": ["1.2.3.4"]}, + "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") + + class DummyModule(BaseModule): + watched_events = ["DNS_NAME"] + _name = "dummy_module" + + async def handle_event(self, event): + new_event = None + if event.data == "evilcorp.com": + new_event = scan.make_event( + "one.evilcorp.com", + "DNS_NAME", + event, + context="{module} invoked forbidden magick to discover {event.type} {event.data}", + module=dummy_module_1, + ) + elif event.data == "one.evilcorp.com": + new_event = scan.make_event( + "two.evilcorp.com", + "DNS_NAME", + event, + context="{module} pledged its allegiance to cthulu and was awarded {event.type} {event.data}", + module=dummy_module_1, + ) + elif event.data == "two.evilcorp.com": + new_event = scan.make_event( + "three.evilcorp.com", + "DNS_NAME", + event, + context="{module} asked nicely and was given {event.type} {event.data}", + module=dummy_module_2, + ) + elif event.data == "three.evilcorp.com": + new_event = scan.make_event( + "four.evilcorp.com", + "DNS_NAME", + event, + context="{module} used brute force to obtain {event.type} {event.data}", + module=dummy_module_2, + ) + if new_event is not None: + await self.emit_event(new_event) + + dummy_module = DummyModule(scan) + + scan.modules["dummy_module"] = dummy_module + + test_event = dummy_module.make_event("evilcorp.com", "DNS_NAME", parent=scan.root_event) + assert test_event.discovery_context == "dummy_module discovered DNS_NAME: evilcorp.com" + + events = [e async for e in scan.async_start()] + assert len(events) == 6 + + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" + and e.data == "evilcorp.com" + and e.discovery_context == f"Scan {scan.name} seeded with DNS_NAME: evilcorp.com" + and e.discovery_path == [f"Scan {scan.name} seeded with DNS_NAME: evilcorp.com"] + ] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" + and e.data == "one.evilcorp.com" + and e.discovery_context == "module_1 invoked forbidden magick to discover DNS_NAME one.evilcorp.com" + and e.discovery_path + == [ + f"Scan {scan.name} seeded with DNS_NAME: evilcorp.com", + "module_1 invoked forbidden magick to discover DNS_NAME one.evilcorp.com", + ] + ] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" + and e.data == "two.evilcorp.com" + and e.discovery_context + == "module_1 pledged its allegiance to cthulu and was awarded DNS_NAME two.evilcorp.com" + and e.discovery_path + == [ + f"Scan {scan.name} seeded with DNS_NAME: evilcorp.com", + "module_1 invoked forbidden magick to discover DNS_NAME one.evilcorp.com", + "module_1 pledged its allegiance to cthulu and was awarded DNS_NAME two.evilcorp.com", + ] + ] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" + and e.data == "three.evilcorp.com" + and e.discovery_context == "module_2 asked nicely and was given DNS_NAME three.evilcorp.com" + and e.discovery_path + == [ + f"Scan {scan.name} seeded with DNS_NAME: evilcorp.com", + "module_1 invoked forbidden magick to discover DNS_NAME one.evilcorp.com", + "module_1 pledged its allegiance to cthulu and was awarded DNS_NAME two.evilcorp.com", + "module_2 asked nicely and was given DNS_NAME three.evilcorp.com", + ] + ] + ) + final_path = [ + f"Scan {scan.name} seeded with DNS_NAME: evilcorp.com", + "module_1 invoked forbidden magick to discover DNS_NAME one.evilcorp.com", + "module_1 pledged its allegiance to cthulu and was awarded DNS_NAME two.evilcorp.com", + "module_2 asked nicely and was given DNS_NAME three.evilcorp.com", + "module_2 used brute force to obtain DNS_NAME four.evilcorp.com", + ] + final_event = [ + e + for e in events + if e.type == "DNS_NAME" + and e.data == "four.evilcorp.com" + and e.discovery_context == "module_2 used brute force to obtain DNS_NAME four.evilcorp.com" + and e.discovery_path == final_path + ] + assert 1 == len(final_event) + j = final_event[0].json() + assert j["discovery_path"] == final_path + + # 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.helpers.dns._mock_dns( + {"blacklanternsecurity.com": {"TXT": ["blsops.com"], "A": ["127.0.0.1"]}, "blsops.com": {"A": ["127.0.0.1"]}} + ) + events = [e async for e in scan.async_start()] + blsops_event = [e for e in events if e.type == "DNS_NAME" and e.data == "blsops.com"] + assert len(blsops_event) == 1 + assert blsops_event[0].discovery_path[1] == "URL_UNVERIFIED has host DNS_NAME: blacklanternsecurity.com" diff --git a/bbot/test/test_step_1/test_files.py b/bbot/test/test_step_1/test_files.py index be52b1cd2..ed9bc0a33 100644 --- a/bbot/test/test_step_1/test_files.py +++ b/bbot/test/test_step_1/test_files.py @@ -4,8 +4,8 @@ @pytest.mark.asyncio -async def test_files(bbot_scanner, bbot_config): - scan1 = bbot_scanner(config=bbot_config) +async def test_files(bbot_scanner): + scan1 = bbot_scanner() # tempfile tempfile = scan1.helpers.tempfile(("line1", "line2"), pipe=False) diff --git a/bbot/test/test_step_1/test_helpers.py b/bbot/test/test_step_1/test_helpers.py index 0045c7652..a289f2ff2 100644 --- a/bbot/test/test_step_1/test_helpers.py +++ b/bbot/test/test_step_1/test_helpers.py @@ -6,7 +6,7 @@ @pytest.mark.asyncio -async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_httpserver): +async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_httpserver): ### URL ### bad_urls = ( "http://e.co/index.html", @@ -103,17 +103,7 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https assert helpers.domain_stem("evilcorp.co.uk") == "evilcorp" assert helpers.domain_stem("www.evilcorp.co.uk") == "www.evilcorp" - assert helpers.host_in_host("www.evilcorp.com", "evilcorp.com") == True - assert helpers.host_in_host("asdf.www.evilcorp.com", "evilcorp.com") == True - assert helpers.host_in_host("evilcorp.com", "www.evilcorp.com") == False - assert helpers.host_in_host("evilcorp.com", "evilcorp.com") == True - assert helpers.host_in_host("evilcorp.com", "eevilcorp.com") == False - assert helpers.host_in_host("eevilcorp.com", "evilcorp.com") == False - assert helpers.host_in_host("evilcorp.com", "evilcorp") == False - assert helpers.host_in_host("evilcorp", "evilcorp.com") == False - assert helpers.host_in_host("evilcorp.com", "com") == True - - assert tuple(helpers.extract_emails("asdf@asdf.com\nT@t.Com&a=a@a.com__ b@b.com")) == ( + assert tuple(await helpers.re.extract_emails("asdf@asdf.com\nT@t.Com&a=a@a.com__ b@b.com")) == ( "asdf@asdf.com", "t@t.com", "a@a.com", @@ -188,7 +178,11 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https assert helpers.subdomain_depth("a.evilcorp.com") == 1 assert helpers.subdomain_depth("a.s.d.f.evilcorp.notreal") == 4 + assert helpers.split_host_port("http://evilcorp.co.uk") == ("evilcorp.co.uk", 80) assert helpers.split_host_port("https://evilcorp.co.uk") == ("evilcorp.co.uk", 443) + assert helpers.split_host_port("ws://evilcorp.co.uk") == ("evilcorp.co.uk", 80) + assert helpers.split_host_port("wss://evilcorp.co.uk") == ("evilcorp.co.uk", 443) + assert helpers.split_host_port("WSS://evilcorp.co.uk") == ("evilcorp.co.uk", 443) assert helpers.split_host_port("http://evilcorp.co.uk:666") == ("evilcorp.co.uk", 666) assert helpers.split_host_port("evilcorp.co.uk:666") == ("evilcorp.co.uk", 666) assert helpers.split_host_port("evilcorp.co.uk") == ("evilcorp.co.uk", None) @@ -224,7 +218,7 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https assert helpers.get_file_extension("/etc/passwd") == "" assert helpers.tagify("HttP -_Web Title-- ") == "http-web-title" - tagged_event = scan.make_event("127.0.0.1", source=scan.root_event, tags=["HttP web -__- title "]) + tagged_event = scan.make_event("127.0.0.1", parent=scan.root_event, tags=["HttP web -__- title "]) assert "http-web-title" in tagged_event.tags tagged_event.remove_tag("http-web-title") assert "http-web-title" not in tagged_event.tags @@ -254,6 +248,12 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https "https://www.evilcorp.com/fdsa", } + replaced = helpers.search_format_dict( + {"asdf": [{"wat": {"here": "#{replaceme}!"}}, {500: True}]}, replaceme="asdf" + ) + assert replaced["asdf"][1][500] == True + assert replaced["asdf"][0]["wat"]["here"] == "asdf!" + filtered_dict = helpers.filter_dict( {"modules": {"c99": {"api_key": "1234", "filterme": "asdf"}, "ipneighbor": {"test": "test"}}}, "api_key" ) @@ -321,12 +321,6 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https assert "secret" not in cleaned_dict4["modules"]["ipneighbor"] assert "asdf" in cleaned_dict4["modules"]["ipneighbor"] - replaced = helpers.search_format_dict( - {"asdf": [{"wat": {"here": "#{replaceme}!"}}, {500: True}]}, replaceme="asdf" - ) - assert replaced["asdf"][1][500] == True - assert replaced["asdf"][0]["wat"]["here"] == "asdf!" - assert helpers.split_list([1, 2, 3, 4, 5]) == [[1, 2], [3, 4, 5]] assert list(helpers.grouper("ABCDEFG", 3)) == [["A", "B", "C"], ["D", "E", "F"], ["G"]] @@ -393,6 +387,18 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https assert helpers.validators.soft_validate("!@#$", "port") == False with pytest.raises(ValueError): helpers.validators.validate_port("asdf") + # top tcp ports + top_tcp_ports = helpers.top_tcp_ports(100) + assert len(top_tcp_ports) == 100 + assert len(set(top_tcp_ports)) == 100 + top_tcp_ports = helpers.top_tcp_ports(800000) + assert top_tcp_ports[:10] == [80, 23, 443, 21, 22, 25, 3389, 110, 445, 139] + assert top_tcp_ports[-10:] == [65526, 65527, 65528, 65529, 65530, 65531, 65532, 65533, 65534, 65535] + assert len(top_tcp_ports) == 65535 + assert len(set(top_tcp_ports)) == 65535 + assert all([isinstance(i, int) for i in top_tcp_ports]) + top_tcp_ports = helpers.top_tcp_ports(10, as_string=True) + assert top_tcp_ports == "80,23,443,21,22,25,3389,110,445,139" # urls assert helpers.validators.validate_url(" httP://evilcorP.com/asdf?a=b&c=d#e") == "http://evilcorp.com/asdf" assert ( @@ -428,20 +434,30 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https assert helpers.smart_encode_punycode("ドメイン.テスト:80") == "xn--eckwd4c7c.xn--zckzah:80" assert helpers.smart_decode_punycode("xn--eckwd4c7c.xn--zckzah:80") == "ドメイン.テスト:80" - assert helpers.recursive_decode("Hello%20world%21") == "Hello world!" - assert helpers.recursive_decode("Hello%20%5Cu041f%5Cu0440%5Cu0438%5Cu0432%5Cu0435%5Cu0442") == "Hello Привет" - assert helpers.recursive_decode("%5Cu0020%5Cu041f%5Cu0440%5Cu0438%5Cu0432%5Cu0435%5Cu0442%5Cu0021") == " Привет!" - assert helpers.recursive_decode("Hello%2520world%2521") == "Hello world!" + assert await helpers.re.recursive_decode("Hello%20world%21") == "Hello world!" + assert ( + await helpers.re.recursive_decode("Hello%20%5Cu041f%5Cu0440%5Cu0438%5Cu0432%5Cu0435%5Cu0442") == "Hello Привет" + ) + assert ( + await helpers.re.recursive_decode("%5Cu0020%5Cu041f%5Cu0440%5Cu0438%5Cu0432%5Cu0435%5Cu0442%5Cu0021") + == " Привет!" + ) + assert await helpers.re.recursive_decode("Hello%2520world%2521") == "Hello world!" assert ( - helpers.recursive_decode("Hello%255Cu0020%255Cu041f%255Cu0440%255Cu0438%255Cu0432%255Cu0435%255Cu0442") + await helpers.re.recursive_decode( + "Hello%255Cu0020%255Cu041f%255Cu0440%255Cu0438%255Cu0432%255Cu0435%255Cu0442" + ) == "Hello Привет" ) assert ( - helpers.recursive_decode("%255Cu0020%255Cu041f%255Cu0440%255Cu0438%255Cu0432%255Cu0435%255Cu0442%255Cu0021") + await helpers.re.recursive_decode( + "%255Cu0020%255Cu041f%255Cu0440%255Cu0438%255Cu0432%255Cu0435%255Cu0442%255Cu0021" + ) == " Привет!" ) assert ( - helpers.recursive_decode(r"Hello\\nWorld\\\tGreetings\\\\nMore\nText") == "Hello\nWorld\tGreetings\nMore\nText" + await helpers.re.recursive_decode(r"Hello\\nWorld\\\tGreetings\\\\nMore\nText") + == "Hello\nWorld\tGreetings\nMore\nText" ) ### CACHE ### @@ -496,11 +512,11 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https with pytest.raises(NTLMError): helpers.ntlm.ntlmdecode("asdf") - test_filesize = Path("/tmp/test_filesize") + test_filesize = bbot_test_dir / "test_filesize" test_filesize.touch() assert test_filesize.is_file() assert helpers.filesize(test_filesize) == 0 - assert helpers.filesize("/tmp/glkasjdlgksadlkfsdf") == 0 + assert helpers.filesize(bbot_test_dir / "glkasjdlgksadlkfsdf") == 0 # memory stuff int(helpers.memory_status().available) @@ -509,6 +525,23 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https assert helpers.bytes_to_human(459819198709) == "428.24GB" assert helpers.human_to_bytes("428.24GB") == 459819198709 + # ordinals + assert helpers.integer_to_ordinal(1) == "1st" + assert helpers.integer_to_ordinal(2) == "2nd" + assert helpers.integer_to_ordinal(3) == "3rd" + assert helpers.integer_to_ordinal(4) == "4th" + assert helpers.integer_to_ordinal(11) == "11th" + assert helpers.integer_to_ordinal(12) == "12th" + assert helpers.integer_to_ordinal(13) == "13th" + assert helpers.integer_to_ordinal(21) == "21st" + assert helpers.integer_to_ordinal(22) == "22nd" + assert helpers.integer_to_ordinal(23) == "23rd" + assert helpers.integer_to_ordinal(101) == "101st" + assert helpers.integer_to_ordinal(111) == "111th" + assert helpers.integer_to_ordinal(112) == "112th" + assert helpers.integer_to_ordinal(113) == "113th" + assert helpers.integer_to_ordinal(0) == "0th" + scan1 = bbot_scanner(modules="ipneighbor") await scan1.load_modules() assert int(helpers.get_size(scan1.modules["ipneighbor"])) > 0 @@ -529,8 +562,39 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_config, bbot_https < first_frequencies["e"] ) - -def test_word_cloud(helpers, bbot_config, bbot_scanner): + # error handling helpers + test_ran = False + try: + try: + raise KeyboardInterrupt("asdf") + except KeyboardInterrupt: + raise ValueError("asdf") + except Exception as e: + assert len(helpers.get_exception_chain(e)) == 2 + assert len([_ for _ in helpers.get_exception_chain(e) if isinstance(_, KeyboardInterrupt)]) == 1 + assert len([_ for _ in helpers.get_exception_chain(e) if isinstance(_, ValueError)]) == 1 + assert helpers.in_exception_chain(e, (KeyboardInterrupt, asyncio.CancelledError)) == True + assert helpers.in_exception_chain(e, (TypeError, OSError)) == False + test_ran = True + assert test_ran + test_ran = False + try: + try: + raise AttributeError("asdf") + except AttributeError: + raise ValueError("asdf") + except Exception as e: + assert len(helpers.get_exception_chain(e)) == 2 + assert len([_ for _ in helpers.get_exception_chain(e) if isinstance(_, AttributeError)]) == 1 + assert len([_ for _ in helpers.get_exception_chain(e) if isinstance(_, ValueError)]) == 1 + assert helpers.in_exception_chain(e, (KeyboardInterrupt, asyncio.CancelledError)) == False + assert helpers.in_exception_chain(e, (KeyboardInterrupt, AttributeError)) == True + assert helpers.in_exception_chain(e, (AttributeError,)) == True + test_ran = True + assert test_ran + + +def test_word_cloud(helpers, bbot_scanner): number_mutations = helpers.word_cloud.get_number_mutations("base2_p013", n=5, padding=2) assert "base0_p013" in number_mutations assert "base7_p013" in number_mutations @@ -546,7 +610,7 @@ def test_word_cloud(helpers, bbot_config, bbot_scanner): assert ("dev", "_base") in permutations # saving and loading - scan1 = bbot_scanner("127.0.0.1", config=bbot_config) + scan1 = bbot_scanner("127.0.0.1") word_cloud = scan1.helpers.word_cloud word_cloud.add_word("lantern") word_cloud.add_word("black") @@ -741,3 +805,99 @@ def test_liststring_invalidfnchars(helpers): with pytest.raises(ValueError) as e: helpers.chain_lists("hello,world,bbot|test", validate=True) assert str(e.value) == "Invalid character in string: bbot|test" + + +# test parameter validation +@pytest.mark.asyncio +async def test_parameter_validation(helpers): + + getparam_valid_params = { + "name", + "age", + "valid_name", + "valid-name", + "session_token", + "user.id", + "user-name", + "client.id", + "auth-token", + "access_token", + "abcd", + "jqueryget", + " + + + +

Simple GET Form

+

Use the form below to submit a GET request:

+
+ +

+ +
+

Simple POST Form

+

Use the form below to submit a POST request:

+
+ +

+ +
+

Links

+
href + img + + + + """ async def setup_before_prep(self, module_test): - module_test.set_expect_requests( - dict(uri="/"), - dict(response_data=''), + module_test.httpserver.expect_request("/").respond_with_data(self.parameter_extraction_html) + + def check(self, module_test, events): + found_jquery_get = False + found_jquery_post = False + found_form_get = False + found_form_post = False + found_jquery_get_original_value = False + found_jquery_post_original_value = False + found_form_get_original_value = False + found_form_post_original_value = False + found_htmltags_a = False + found_htmltags_img = False + + for e in events: + if e.type == "WEB_PARAMETER": + if e.data["description"] == "HTTP Extracted Parameter [jqueryget] (GET jquery Submodule)": + found_jquery_get = True + if e.data["original_value"] == "value1": + found_jquery_get_original_value = True + + if e.data["description"] == "HTTP Extracted Parameter [jquerypost] (POST jquery Submodule)": + found_jquery_post = True + if e.data["original_value"] == "value2": + found_jquery_post_original_value = True + + if e.data["description"] == "HTTP Extracted Parameter [q] (GET Form Submodule)": + found_form_get = True + if e.data["original_value"] == "flowers": + found_form_get_original_value = True + + if e.data["description"] == "HTTP Extracted Parameter [q] (POST Form Submodule)": + found_form_post = True + if e.data["original_value"] == "boats": + found_form_post_original_value = True + + if e.data["description"] == "HTTP Extracted Parameter [age] (HTML Tags Submodule)": + if e.data["original_value"] == "456": + if "id" in e.data["additional_params"].keys(): + found_htmltags_a = True + + if e.data["description"] == "HTTP Extracted Parameter [size] (HTML Tags Submodule)": + if e.data["original_value"] == "m": + if "fit" in e.data["additional_params"].keys(): + found_htmltags_img = True + + assert found_jquery_get, "Did not extract Jquery GET parameters" + assert found_jquery_post, "Did not extract Jquery POST parameters" + assert found_form_get, "Did not extract Form GET parameters" + assert found_form_post, "Did not extract Form POST parameters" + assert found_jquery_get_original_value, "Did not extract Jquery GET parameter original_value" + assert found_jquery_post_original_value, "Did not extract Jquery POST parameter original_value" + assert found_form_get_original_value, "Did not extract Form GET parameter original_value" + assert found_form_post_original_value, "Did not extract Form POST parameter original_value" + assert found_htmltags_a, "Did not extract parameter(s) from a-tag" + assert found_htmltags_img, "Did not extract parameter(s) from img-tag" + + +class TestExcavateParameterExtraction_getparam(ModuleTestBase): + + targets = ["http://127.0.0.1:8888/"] + + # hunt is added as parameter extraction is only activated by one or more modules that consume WEB_PARAMETER + modules_overrides = ["httpx", "excavate", "hunt"] + getparam_extract_html = """ +ping + """ + + async def setup_after_prep(self, module_test): + respond_args = {"response_data": self.getparam_extract_html, "headers": {"Content-Type": "text/html"}} + module_test.set_expect_requests(respond_args=respond_args) + + def check(self, module_test, events): + + excavate_getparam_extraction = False + for e in events: + if e.type == "WEB_PARAMETER": + + if "HTTP Extracted Parameter [hack] (HTML Tags Submodule)" in e.data["description"]: + excavate_getparam_extraction = True + assert excavate_getparam_extraction, "Excavate failed to extract web parameter" + + +class TestExcavateParameterExtraction_json(ModuleTestBase): + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["httpx", "excavate", "paramminer_getparams"] + config_overrides = {"modules": {"paramminer_getparams": {"wordlist": tempwordlist([]), "recycle_words": True}}} + getparam_extract_json = """ + { + "obscureParameter": 1, + "common": 1 +} + """ + + async def setup_after_prep(self, module_test): + respond_args = {"response_data": self.getparam_extract_json, "headers": {"Content-Type": "application/json"}} + module_test.set_expect_requests(respond_args=respond_args) + + def check(self, module_test, events): + excavate_json_extraction = False + for e in events: + if e.type == "WEB_PARAMETER": + if ( + "HTTP Extracted Parameter (speculative from json content) [obscureParameter]" + in e.data["description"] + ): + excavate_json_extraction = True + assert excavate_json_extraction, "Excavate failed to extract json parameter" + + +class TestExcavateParameterExtraction_xml(ModuleTestBase): + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["httpx", "excavate", "paramminer_getparams"] + config_overrides = {"modules": {"paramminer_getparams": {"wordlist": tempwordlist([]), "recycle_words": True}}} + getparam_extract_xml = """ + + 1 + 1 + + """ + + async def setup_after_prep(self, module_test): + respond_args = {"response_data": self.getparam_extract_xml, "headers": {"Content-Type": "application/xml"}} + module_test.set_expect_requests(respond_args=respond_args) + + def check(self, module_test, events): + excavate_xml_extraction = False + for e in events: + if e.type == "WEB_PARAMETER": + if ( + "HTTP Extracted Parameter (speculative from xml content) [obscureParameter]" + in e.data["description"] + ): + excavate_xml_extraction = True + assert excavate_xml_extraction, "Excavate failed to extract xml parameter" + + +class excavateTestRule(ExcavateRule): + yara_rules = { + "SearchForText": 'rule SearchForText { meta: description = "Contains the text AAAABBBBCCCC" strings: $text = "AAAABBBBCCCC" condition: $text }', + "SearchForText2": 'rule SearchForText2 { meta: description = "Contains the text DDDDEEEEFFFF" strings: $text2 = "DDDDEEEEFFFF" condition: $text2 }', + } + + +class TestExcavateYara(TestExcavate): + + targets = ["http://127.0.0.1:8888/"] + yara_test_html = """ + + + + +

AAAABBBBCCCC

+

filler

+

DDDDEEEEFFFF

+ + +""" + + async def setup_before_prep(self, module_test): + + self.modules_overrides = ["excavate", "httpx"] + module_test.httpserver.expect_request("/").respond_with_data(self.yara_test_html) + + async def setup_after_prep(self, module_test): + + excavate_module = module_test.scan.modules["excavate"] + excavateruleinstance = excavateTestRule(excavate_module) + excavate_module.add_yara_rule( + "SearchForText", + 'rule SearchForText { meta: description = "Contains the text AAAABBBBCCCC" strings: $text = "AAAABBBBCCCC" condition: $text }', + excavateruleinstance, ) - module_test.set_expect_requests( - dict(uri="/Test_PDF"), - dict(response_data=self.pdf_data, headers={"Content-Type": "application/pdf"}), + excavate_module.add_yara_rule( + "SearchForText2", + 'rule SearchForText2 { meta: description = "Contains the text DDDDEEEEFFFF" strings: $text2 = "DDDDEEEEFFFF" condition: $text2 }', + excavateruleinstance, ) + excavate_module.yara_rules = yara.compile(source="\n".join(excavate_module.yara_rules_dict.values())) def check(self, module_test, events): - assert any( - e.type == "URL_UNVERIFIED" - and e.data == "http://127.0.0.1:8888/distance2.html" - and "spider-danger" in e.tags - for e in events + found_yara_string_1 = False + found_yara_string_2 = False + for e in events: + + if e.type == "FINDING": + if e.data["description"] == "HTTP response (body) Contains the text AAAABBBBCCCC": + found_yara_string_1 = True + if e.data["description"] == "HTTP response (body) Contains the text DDDDEEEEFFFF": + found_yara_string_2 = True + + assert found_yara_string_1, "Did not extract Match YARA rule (1)" + assert found_yara_string_2, "Did not extract Match YARA rule (2)" + + +class TestExcavateYaraCustom(TestExcavateYara): + + rule_file = [ + 'rule SearchForText { meta: description = "Contains the text AAAABBBBCCCC" strings: $text = "AAAABBBBCCCC" condition: $text }', + 'rule SearchForText2 { meta: description = "Contains the text DDDDEEEEFFFF" strings: $text2 = "DDDDEEEEFFFF" condition: $text2 }', + ] + f = tempwordlist(rule_file) + config_overrides = {"modules": {"excavate": {"custom_yara_rules": f}}} + + +class TestExcavateSpiderDedupe(ModuleTestBase): + class DummyModule(BaseModule): + watched_events = ["URL_UNVERIFIED"] + _name = "dummy_module" + + events_seen = [] + + async def handle_event(self, event): + await self.helpers.sleep(0.5) + self.events_seen.append(event.data) + new_event = self.scan.make_event(event.data, "URL_UNVERIFIED", self.scan.root_event) + if new_event is not None: + await self.emit_event(new_event) + + dummy_text = "spider" + modules_overrides = ["excavate", "httpx"] + targets = ["http://127.0.0.1:8888/"] + + 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.httpserver.expect_request("/").respond_with_data(self.dummy_text) + module_test.httpserver.expect_request("/spider").respond_with_data("hi") + + def check(self, module_test, events): + + found_url_unverified_spider_max = False + found_url_unverified_dummy = False + found_url_event = False + + assert sorted(self.dummy_module.events_seen) == ["http://127.0.0.1:8888/", "http://127.0.0.1:8888/spider"] + + for e in events: + if e.type == "URL_UNVERIFIED": + if e.data == "http://127.0.0.1:8888/spider": + if str(e.module) == "excavate" and "spider-danger" in e.tags and "spider-max" in e.tags: + found_url_unverified_spider_max = True + if ( + str(e.module) == "dummy_module" + and "spider-danger" not in e.tags + and not "spider-max" in e.tags + ): + found_url_unverified_dummy = True + if e.type == "URL" and e.data == "http://127.0.0.1:8888/spider": + found_url_event = True + + assert found_url_unverified_spider_max, "Excavate failed to find /spider link" + assert found_url_unverified_dummy, "Dummy module did not correctly re-emit" + assert found_url_event, "URL was not emitted from non-spider-max URL_UNVERIFIED" + + +class TestExcavateParameterExtraction_targeturl(ModuleTestBase): + targets = ["http://127.0.0.1:8888/?foo=1"] + modules_overrides = ["httpx", "excavate", "hunt"] + config_overrides = { + "url_querystring_remove": False, + "url_querystring_collapse": False, + "interactsh_disable": True, + "modules": { + "excavate": { + "retain_querystring": True, + } + }, + } + + async def setup_after_prep(self, module_test): + expect_args = {"method": "GET", "uri": "/", "query_string": "foo=1"} + respond_args = { + "response_data": "alive", + "status": 200, + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + def check(self, module_test, events): + web_parameter_emit = False + for e in events: + if e.type == "WEB_PARAMETER" and "HTTP Extracted Parameter [foo] (Target URL)" in e.data["description"]: + web_parameter_emit = True + + assert web_parameter_emit + + +class TestExcavate_retain_querystring(ModuleTestBase): + targets = ["http://127.0.0.1:8888/?foo=1"] + modules_overrides = ["httpx", "excavate", "hunt"] + config_overrides = { + "url_querystring_remove": False, + "url_querystring_collapse": False, + "interactsh_disable": True, + "web_spider_depth": 4, + "web_spider_distance": 4, + "modules": { + "excavate": { + "retain_querystring": True, + } + }, + } + + async def setup_after_prep(self, module_test): + expect_args = {"method": "GET", "uri": "/", "query_string": "foo=1"} + respond_args = { + "response_data": "alive", + "headers": {"Set-Cookie": "a=b"}, + "status": 200, + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + def check(self, module_test, events): + web_parameter_emit = False + for e in events: + if e.type == "WEB_PARAMETER" and "foo" in e.data["url"]: + web_parameter_emit = True + + assert web_parameter_emit + + +class TestExcavate_retain_querystring_not(TestExcavate_retain_querystring): + + config_overrides = { + "url_querystring_remove": False, + "url_querystring_collapse": False, + "interactsh_disable": True, + "web_spider_depth": 4, + "web_spider_distance": 4, + "modules": { + "excavate": { + "retain_querystring": True, + } + }, + } + + def check(self, module_test, events): + web_parameter_emit = False + for e in events: + if e.type == "WEB_PARAMETER" and "foo" not in e.data["url"]: + web_parameter_emit = True + + assert web_parameter_emit + + +class TestExcavate_webparameter_outofscope(ModuleTestBase): + + html_body = "" + + targets = ["http://127.0.0.1:8888", "socialmediasite.com"] + modules_overrides = ["httpx", "excavate", "hunt"] + config_overrides = {"interactsh_disable": True} + + async def setup_after_prep(self, module_test): + expect_args = {"method": "GET", "uri": "/"} + respond_args = { + "response_data": self.html_body, + "status": 200, + } + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + def check(self, module_test, events): + web_parameter_differentsite = False + web_parameter_outofscope = False + + for e in events: + if e.type == "WEB_PARAMETER" and "in-scope" in e.tags and e.host == "socialmediasite.com": + web_parameter_differentsite = True + + if e.type == "WEB_PARAMETER" and e.host == "outofscope.com": + web_parameter_outofscope = True + + assert web_parameter_differentsite, "WEB_PARAMETER was not emitted" + assert not web_parameter_outofscope, "Out of scope domain was emitted" + + +class TestExcavateHeaders(ModuleTestBase): + + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["excavate", "httpx"] + config_overrides = {"web": {"spider_distance": 1, "spider_depth": 1}} + + async def setup_before_prep(self, module_test): + + module_test.httpserver.expect_request("/").respond_with_data( + "

test

", + status=200, + headers={ + "Set-Cookie": [ + "COOKIE1=aaaa; Secure; HttpOnly", + "COOKIE2=bbbb; Secure; HttpOnly; SameSite=None", + ] + }, ) + + def check(self, module_test, events): + + found_first_cookie = False + found_second_cookie = False + + for e in events: + if e.type == "WEB_PARAMETER": + if e.data["name"] == "COOKIE1": + found_first_cookie = True + if e.data["name"] == "COOKIE2": + found_second_cookie = True + + assert found_first_cookie == True + assert found_second_cookie == True diff --git a/bbot/test/test_step_2/module_tests/test_module_ffuf.py b/bbot/test/test_step_2/module_tests/test_module_ffuf.py index 8c48f353b..b1296a5b9 100644 --- a/bbot/test/test_step_2/module_tests/test_module_ffuf.py +++ b/bbot/test/test_step_2/module_tests/test_module_ffuf.py @@ -49,7 +49,7 @@ class TestFFUFHeaders(TestFFUF): test_wordlist = ["11111111", "console", "junkword1", "zzzjunkword2"] config_overrides = { "modules": {"ffuf": {"wordlist": tempwordlist(test_wordlist), "extensions": "php"}}, - "http_headers": {"test": "test2"}, + "web": {"http_headers": {"test": "test2"}}, } async def setup_before_prep(self, module_test): diff --git a/bbot/test/test_step_2/module_tests/test_module_ffuf_shortnames.py b/bbot/test/test_step_2/module_tests/test_module_ffuf_shortnames.py index cbbec11ea..00c1f9b1e 100644 --- a/bbot/test/test_step_2/module_tests/test_module_ffuf_shortnames.py +++ b/bbot/test/test_step_2/module_tests/test_module_ffuf_shortnames.py @@ -7,7 +7,6 @@ class TestFFUFShortnames(ModuleTestBase): config_overrides = { "modules": { "ffuf_shortnames": { - "find_common_prefixes": True, "find_common_prefixes": True, "wordlist": tempwordlist(test_wordlist), } @@ -143,7 +142,7 @@ async def setup_after_prep(self, module_test): tags=["shortname-file"], ) ) - module_test.scan.target._events["http://127.0.0.1:8888"] = seed_events + module_test.scan.target.seeds._events = set(seed_events) expect_args = {"method": "GET", "uri": "/administrator.aspx"} respond_args = {"response_data": "alive"} diff --git a/bbot/test/test_step_2/module_tests/test_module_filedownload.py b/bbot/test/test_step_2/module_tests/test_module_filedownload.py index 2d46b697e..0b949e961 100644 --- a/bbot/test/test_step_2/module_tests/test_module_filedownload.py +++ b/bbot/test/test_step_2/module_tests/test_module_filedownload.py @@ -5,7 +5,7 @@ class TestFileDownload(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["filedownload", "httpx", "excavate", "speculate"] - config_overrides = {"web_spider_distance": 2, "web_spider_depth": 2} + config_overrides = {"web": {"spider_distance": 2, "spider_depth": 2}} pdf_data = """%PDF-1. 1 0 obj<>endobj 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 370dd151a..31d05fdc1 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 @@ -16,19 +16,26 @@ class TestGeneric_SSRF(ModuleTestBase): modules_overrides = ["httpx", "generic_ssrf"] def request_handler(self, request): + self.log.critical(str(request)) + self.log.critical(request.full_path) + self.log.critical(request.data.decode()) 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()) + self.log.critical(f"subdomain tag: {subdomain_tag}") if subdomain_tag: - self.interactsh_mock_instance.mock_interaction(subdomain_tag) + 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.request_fixture.getfixturevalue("interactsh_mock_instance") + self.interactsh_mock_instance = module_test.mock_interactsh("generic_ssrf") + self.interactsh_mock_instance.mock_interaction("asdf") 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_github_codesearch.py b/bbot/test/test_step_2/module_tests/test_module_github_codesearch.py index aacb5eb88..80693192b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_codesearch.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_codesearch.py @@ -5,7 +5,7 @@ class TestGithub_Codesearch(ModuleTestBase): config_overrides = { "modules": {"github_codesearch": {"api_key": "asdf", "limit": 1}}, "omit_event_types": [], - "scope_report_distance": 1, + "scope": {"report_distance": 1}, } modules_overrides = ["github_codesearch", "httpx", "secretsdb"] 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 5f15d0892..b75d51238 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 @@ -6,6 +6,10 @@ class TestGithub_Org(ModuleTestBase): modules_overrides = ["github_org", "speculate"] async def setup_before_prep(self, module_test): + await module_test.mock_dns( + {"blacklanternsecurity.com": {"A": ["127.0.0.99"]}, "github.com": {"A": ["127.0.0.99"]}} + ) + module_test.httpx_mock.add_response(url="https://api.github.com/zen") module_test.httpx_mock.add_response( url="https://api.github.com/orgs/blacklanternsecurity", @@ -280,7 +284,7 @@ async def setup_before_prep(self, module_test): ) def check(self, module_test, events): - assert len(events) == 6 + assert len(events) == 7 assert 1 == len( [ e @@ -298,10 +302,22 @@ def check(self, module_test, events): if e.type == "SOCIAL" and e.data["platform"] == "github" and e.data["profile_name"] == "blacklanternsecurity" + and str(e.module) == "github_org" and "github-org" in e.tags and e.scope_distance == 1 ] ), "Failed to find blacklanternsecurity github" + assert 1 == len( + [ + e + for e in events + if e.type == "SOCIAL" + and e.data["platform"] == "github" + and e.data["profile_name"] == "blacklanternsecurity" + and str(e.module) == "social" + and e.scope_distance == 1 + ] + ), "Failed to find blacklanternsecurity github (social module)" assert 1 == len( [ e @@ -309,6 +325,7 @@ def check(self, module_test, events): if e.type == "SOCIAL" and e.data["platform"] == "github" and e.data["profile_name"] == "TheTechromancer" + and str(e.module) == "github_org" and "github-org-member" in e.tags and e.scope_distance == 2 ] @@ -329,7 +346,7 @@ class TestGithub_Org_No_Members(TestGithub_Org): config_overrides = {"modules": {"github_org": {"include_members": False}}} def check(self, module_test, events): - assert len(events) == 5 + assert len(events) == 6 assert 1 == len( [ e @@ -337,10 +354,22 @@ def check(self, module_test, events): if e.type == "SOCIAL" and e.data["platform"] == "github" and e.data["profile_name"] == "blacklanternsecurity" + and str(e.module) == "github_org" and "github-org" in e.tags and e.scope_distance == 1 ] ), "Failed to find blacklanternsecurity github" + assert 1 == len( + [ + e + for e in events + if e.type == "SOCIAL" + and e.data["platform"] == "github" + and e.data["profile_name"] == "blacklanternsecurity" + and str(e.module) == "social" + and e.scope_distance == 1 + ] + ), "Failed to find blacklanternsecurity github (social module)" assert 0 == len( [ e @@ -356,7 +385,7 @@ class TestGithub_Org_MemberRepos(TestGithub_Org): config_overrides = {"modules": {"github_org": {"include_member_repos": True}}} def check(self, module_test, events): - assert len(events) == 7 + assert len(events) == 8 assert 1 == len( [ e @@ -366,15 +395,15 @@ def check(self, module_test, events): and e.data["url"] == "https://github.com/TheTechromancer/websitedemo" and e.scope_distance == 2 ] - ), "Found to find TheTechromancer github repo" + ), "Failed to find TheTechromancer github repo" class TestGithub_Org_Custom_Target(TestGithub_Org): targets = ["ORG:blacklanternsecurity"] - config_overrides = {"scope_report_distance": 10, "omit_event_types": [], "speculate": True} + config_overrides = {"scope": {"report_distance": 10}, "omit_event_types": [], "speculate": True} def check(self, module_test, events): - assert len(events) == 7 + assert len(events) == 8 assert 1 == len( [e for e in events if e.type == "ORG_STUB" and e.data == "blacklanternsecurity" and e.scope_distance == 1] ) @@ -386,6 +415,20 @@ def check(self, module_test, events): and e.data["platform"] == "github" and e.data["profile_name"] == "blacklanternsecurity" and e.scope_distance == 1 + and str(e.module) == "social" + and e.parent.type == "URL_UNVERIFIED" + ] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "SOCIAL" + and e.data["platform"] == "github" + and e.data["profile_name"] == "blacklanternsecurity" + and e.scope_distance == 1 + and str(e.module) == "github_org" + and e.parent.type == "ORG_STUB" ] ) assert 1 == len( diff --git a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py index c5bdf6d07..4cb6fff41 100644 --- a/bbot/test/test_step_2/module_tests/test_module_github_workflows.py +++ b/bbot/test/test_step_2/module_tests/test_module_github_workflows.py @@ -439,7 +439,7 @@ async def setup_before_prep(self, module_test): ) def check(self, module_test, events): - assert len(events) == 7 + assert len(events) == 8 assert 1 == len( [ e @@ -457,7 +457,20 @@ def check(self, module_test, events): if e.type == "SOCIAL" and e.data["platform"] == "github" and e.data["profile_name"] == "blacklanternsecurity" - and "github-org" in e.tags + and e.data["url"] == "https://github.com/blacklanternsecurity" + and str(e.module) == "github_org" + and e.scope_distance == 1 + ] + ), "Failed to find blacklanternsecurity github" + assert 1 == len( + [ + e + for e in events + if e.type == "SOCIAL" + and e.data["platform"] == "github" + and e.data["profile_name"] == "blacklanternsecurity" + and e.data["url"] == "https://github.com/blacklanternsecurity" + and str(e.module) == "social" and e.scope_distance == 1 ] ), "Failed to find blacklanternsecurity github" diff --git a/bbot/test/test_step_2/module_tests/test_module_gowitness.py b/bbot/test/test_step_2/module_tests/test_module_gowitness.py index b8f843855..b6439dbbb 100644 --- a/bbot/test/test_step_2/module_tests/test_module_gowitness.py +++ b/bbot/test/test_step_2/module_tests/test_module_gowitness.py @@ -9,7 +9,12 @@ class TestGowitness(ModuleTestBase): home_dir = Path("/tmp/.bbot_gowitness_test") shutil.rmtree(home_dir, ignore_errors=True) - config_overrides = {"force_deps": True, "home": str(home_dir), "scope_report_distance": 2, "omit_event_types": []} + config_overrides = { + "force_deps": True, + "home": str(home_dir), + "scope": {"report_distance": 2}, + "omit_event_types": [], + } async def setup_after_prep(self, module_test): respond_args = { @@ -29,10 +34,10 @@ async def setup_after_prep(self, module_test): # monkeypatch social old_emit_event = module_test.scan.modules["social"].emit_event - async def new_emit_event(event): + async def new_emit_event(event, **kwargs): if event.data["url"] == "https://github.com/blacklanternsecurity": event.data["url"] = event.data["url"].replace("https://github.com", "http://127.0.0.1:8888") - await old_emit_event(event) + await old_emit_event(event, **kwargs) module_test.monkeypatch.setattr(module_test.scan.modules["social"], "emit_event", new_emit_event) @@ -46,8 +51,8 @@ def check(self, module_test, events): screenshots_path = self.home_dir / "scans" / module_test.scan.name / "gowitness" / "screenshots" screenshots = list(screenshots_path.glob("*.png")) assert ( - len(screenshots) == 2 - ), f"{len(screenshots):,} .png files found at {screenshots_path}, should have been 2" + len(screenshots) == 1 + ), f"{len(screenshots):,} .png files found at {screenshots_path}, should have been 1" assert 1 == len([e for e in events if e.type == "URL" and e.data == "http://127.0.0.1:8888/"]) assert 1 == len( [e for e in events if e.type == "URL_UNVERIFIED" and e.data == "https://fonts.googleapis.com/"] @@ -56,8 +61,22 @@ def check(self, module_test, events): assert 1 == len( [e for e in events if e.type == "SOCIAL" and e.data["url"] == "http://127.0.0.1:8888/blacklanternsecurity"] ) - assert 2 == len([e for e in events if e.type == "WEBSCREENSHOT"]) + assert 1 == len([e for e in events if e.type == "WEBSCREENSHOT"]) assert 1 == len([e for e in events if e.type == "WEBSCREENSHOT" and e.data["url"] == "http://127.0.0.1:8888/"]) + assert len([e for e in events if e.type == "TECHNOLOGY"]) + + +class TestGowitness_Social(TestGowitness): + config_overrides = dict(TestGowitness.config_overrides) + config_overrides.update({"modules": {"gowitness": {"social": True}}}) + + def check(self, module_test, events): + screenshots_path = self.home_dir / "scans" / module_test.scan.name / "gowitness" / "screenshots" + screenshots = list(screenshots_path.glob("*.png")) + assert ( + len(screenshots) == 2 + ), f"{len(screenshots):,} .png files found at {screenshots_path}, should have been 2" + assert 2 == len([e for e in events if e.type == "WEBSCREENSHOT"]) assert 1 == len( [ e @@ -65,14 +84,13 @@ def check(self, module_test, events): if e.type == "WEBSCREENSHOT" and e.data["url"] == "http://127.0.0.1:8888/blacklanternsecurity" ] ) - assert len([e for e in events if e.type == "TECHNOLOGY"]) assert 1 == len( [ e for e in events if e.type == "TECHNOLOGY" and e.data["url"] == "http://127.0.0.1:8888/blacklanternsecurity" - and e.source.type == "SOCIAL" + and e.parent.type == "SOCIAL" ] ) 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 9741d9bc1..b71a31b1d 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 @@ -34,7 +34,7 @@ def request_handler(self, request): return Response(f"Alive, host is: defaulthost.com", status=200) async def setup_before_prep(self, module_test): - self.interactsh_mock_instance = module_test.request_fixture.getfixturevalue("interactsh_mock_instance") + 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_http.py b/bbot/test/test_step_2/module_tests/test_module_http.py index d0afcefb2..43b7189ad 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 @@ -7,7 +7,7 @@ class TestHTTP(ModuleTestBase): downstream_url = "https://blacklanternsecurity.fakedomain:1234/events" config_overrides = { - "output_modules": { + "modules": { "http": { "url": downstream_url, "method": "PUT", @@ -56,8 +56,8 @@ def check(self, module_test, events): class TestHTTPSIEMFriendly(TestHTTP): modules_overrides = ["http"] - config_overrides = {"output_modules": {"http": dict(TestHTTP.config_overrides["output_modules"]["http"])}} - config_overrides["output_modules"]["http"]["siem_friendly"] = True + 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_httpx.py b/bbot/test/test_step_2/module_tests/test_module_httpx.py index e4970e02e..ef9744516 100644 --- a/bbot/test/test_step_2/module_tests/test_module_httpx.py +++ b/bbot/test/test_step_2/module_tests/test_module_httpx.py @@ -56,7 +56,7 @@ def check(self, module_test, events): class TestHTTPX_404(ModuleTestBase): targets = ["https://127.0.0.1:9999"] modules_overrides = ["httpx", "speculate", "excavate"] - config_overrides = {"internal_modules": {"speculate": {"ports": "8888,9999"}}} + config_overrides = {"modules": {"speculate": {"ports": "8888,9999"}}} async def setup_after_prep(self, module_test): module_test.httpserver.expect_request("/").respond_with_data( @@ -103,7 +103,7 @@ def check(self, module_test, events): class TestHTTPX_URLBlacklist(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx", "speculate", "excavate"] - config_overrides = {"web_spider_distance": 10, "web_spider_depth": 10} + config_overrides = {"web": {"spider_distance": 10, "spider_depth": 10}} async def setup_after_prep(self, module_test): module_test.httpserver.expect_request("/").respond_with_data( @@ -124,3 +124,21 @@ def check(self, module_test, events): assert 1 == len([e for e in events if e.type == "URL" and e.data == "http://127.0.0.1:8888/test.txt"]) assert not any([e for e in events if "URL" in e.type and ".svg" in e.data]) assert not any([e for e in events if "URL" in e.type and ".woff" in e.data]) + + +class TestHTTPX_querystring_removed(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["httpx", "speculate", "excavate"] + + async def setup_after_prep(self, module_test): + module_test.httpserver.expect_request("/").respond_with_data('
') + + def check(self, module_test, events): + assert [e for e in events if e.type == "URL_UNVERIFIED" and e.data == "http://127.0.0.1:8888/test.php"] + + +class TestHTTPX_querystring_notremoved(TestHTTPX_querystring_removed): + config_overrides = {"url_querystring_remove": False} + + def check(self, module_test, events): + assert [e for e in events if e.type == "URL_UNVERIFIED" and e.data == "http://127.0.0.1:8888/test.php?foo=bar"] 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 9c6562609..ff5eed716 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 @@ -3,7 +3,10 @@ class TestHunt(ModuleTestBase): targets = ["http://127.0.0.1:8888"] - modules_overrides = ["httpx", "hunt"] + modules_overrides = ["httpx", "hunt", "excavate"] + config_overrides = { + "interactsh_disable": True, + } 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_internetdb.py b/bbot/test/test_step_2/module_tests/test_module_internetdb.py index d24cdebc0..786ea1f33 100644 --- a/bbot/test/test_step_2/module_tests/test_module_internetdb.py +++ b/bbot/test/test_step_2/module_tests/test_module_internetdb.py @@ -2,10 +2,10 @@ class TestInternetDB(ModuleTestBase): - config_overrides = {"dns_resolution": True} + config_overrides = {"dns": {"minimal": False}} async def setup_before_prep(self, module_test): - module_test.mock_dns( + await module_test.mock_dns( { "blacklanternsecurity.com": {"A": ["1.2.3.4"]}, "autodiscover.blacklanternsecurity.com": {"A": ["2.3.4.5"]}, diff --git a/bbot/test/test_step_2/module_tests/test_module_ipneighbor.py b/bbot/test/test_step_2/module_tests/test_module_ipneighbor.py index b8ba8331a..8d8fdff41 100644 --- a/bbot/test/test_step_2/module_tests/test_module_ipneighbor.py +++ b/bbot/test/test_step_2/module_tests/test_module_ipneighbor.py @@ -3,10 +3,10 @@ class TestIPNeighbor(ModuleTestBase): targets = ["127.0.0.15", "www.bls.notreal"] - config_overrides = {"scope_report_distance": 1, "dns_resolution": True, "scope_dns_search_distance": 2} + config_overrides = {"scope": {"report_distance": 1}, "dns": {"minimal": False, "search_distance": 2}} async def setup_after_prep(self, module_test): - module_test.mock_dns( + await module_test.mock_dns( {"3.0.0.127.in-addr.arpa": {"PTR": ["asdf.www.bls.notreal"]}, "asdf.www.bls.notreal": {"A": ["127.0.0.3"]}} ) 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 1e67db085..6a5215f6e 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 @@ -6,20 +6,46 @@ class TestJSON(ModuleTestBase): def check(self, module_test, events): - txt_file = module_test.scan.home / "output.ndjson" + dns_data = "blacklanternsecurity.com" + context_data = f"Scan {module_test.scan.name} seeded with DNS_NAME: blacklanternsecurity.com" + + # json events + txt_file = module_test.scan.home / "output.json" lines = list(module_test.scan.helpers.read_file(txt_file)) assert lines - e = event_from_json(json.loads(lines[0])) - assert e.type == "SCAN" - assert e.data == f"{module_test.scan.name} ({module_test.scan.id})" + json_events = [json.loads(line) for line in lines] + scan_json = [e for e in json_events if e["type"] == "SCAN"] + dns_json = [e for e in json_events if e["type"] == "DNS_NAME"] + assert len(scan_json) == 1 + assert len(dns_json) == 1 + scan_json = scan_json[0] + dns_json = dns_json[0] + assert scan_json["data"]["name"] == module_test.scan.name + assert scan_json["data"]["id"] == module_test.scan.id + assert scan_json["data"]["target"]["seeds"] == ["blacklanternsecurity.com"] + assert scan_json["data"]["target"]["whitelist"] == ["blacklanternsecurity.com"] + assert dns_json["data"] == dns_data + assert dns_json["discovery_context"] == context_data + assert dns_json["discovery_path"] == [context_data] + + # event objects reconstructed from json + scan_reconstructed = event_from_json(scan_json) + dns_reconstructed = event_from_json(dns_json) + assert scan_reconstructed.data["name"] == module_test.scan.name + assert scan_reconstructed.data["id"] == module_test.scan.id + assert scan_reconstructed.data["target"]["seeds"] == ["blacklanternsecurity.com"] + assert scan_reconstructed.data["target"]["whitelist"] == ["blacklanternsecurity.com"] + assert dns_reconstructed.data == dns_data + assert dns_reconstructed.discovery_context == context_data + assert dns_reconstructed.discovery_path == [context_data] class TestJSONSIEMFriendly(ModuleTestBase): modules_overrides = ["json"] - config_overrides = {"output_modules": {"json": {"siem_friendly": True}}} + config_overrides = {"modules": {"json": {"siem_friendly": True}}} def check(self, module_test, events): - txt_file = module_test.scan.home / "output.ndjson" + txt_file = module_test.scan.home / "output.json" lines = list(module_test.scan.helpers.read_file(txt_file)) passed = False for line in lines: diff --git a/bbot/test/test_step_2/module_tests/test_module_masscan.py b/bbot/test/test_step_2/module_tests/test_module_masscan.py deleted file mode 100644 index c489d8518..000000000 --- a/bbot/test/test_step_2/module_tests/test_module_masscan.py +++ /dev/null @@ -1,40 +0,0 @@ -from .base import ModuleTestBase - - -class TestMasscan(ModuleTestBase): - targets = ["8.8.8.8/32"] - scan_name = "test_masscan" - config_overrides = {"modules": {"masscan": {"ports": "443", "wait": 1}}} - - masscan_output = """[ -{ "ip": "8.8.8.8", "timestamp": "1680197558", "ports": [ {"port": 443, "proto": "tcp", "status": "open", "reason": "syn-ack", "ttl": 54} ] } -]""" - - async def setup_after_prep(self, module_test): - self.masscan_run = False - - async def run_masscan(command, *args, **kwargs): - if "masscan" in command[:2]: - for l in self.masscan_output.splitlines(): - yield l - self.masscan_run = True - else: - async for l in module_test.scan.helpers.run_live(command, *args, **kwargs): - yield l - - module_test.monkeypatch.setattr(module_test.scan.helpers, "run_live", run_masscan) - - def check(self, module_test, events): - assert self.masscan_run == True - assert any(e.type == "IP_ADDRESS" and e.data == "8.8.8.8" for e in events), "No IP_ADDRESS emitted" - assert any(e.type == "OPEN_TCP_PORT" and e.data == "8.8.8.8:443" for e in events), "No OPEN_TCP_PORT emitted" - - -class TestMasscan1(TestMasscan): - modules_overrides = ["masscan"] - config_overrides = {"modules": {"masscan": {"ports": "443", "wait": 1, "use_cache": True}}} - - def check(self, module_test, events): - assert self.masscan_run == False - assert any(e.type == "IP_ADDRESS" and e.data == "8.8.8.8" for e in events), "No IP_ADDRESS emitted" - assert any(e.type == "OPEN_TCP_PORT" and e.data == "8.8.8.8:443" for e in events), "No OPEN_TCP_PORT emitted" diff --git a/bbot/test/test_step_2/module_tests/test_module_massdns.py b/bbot/test/test_step_2/module_tests/test_module_massdns.py deleted file mode 100644 index 1b4543788..000000000 --- a/bbot/test/test_step_2/module_tests/test_module_massdns.py +++ /dev/null @@ -1,10 +0,0 @@ -from .base import ModuleTestBase, tempwordlist - - -class TestMassdns(ModuleTestBase): - subdomain_wordlist = tempwordlist(["www", "asdf"]) - config_overrides = {"modules": {"massdns": {"wordlist": str(subdomain_wordlist)}}} - - def check(self, module_test, events): - assert any(e.data == "www.blacklanternsecurity.com" for e in events) - assert not any(e.data == "asdf.blacklanternsecurity.com" for e in events) diff --git a/bbot/test/test_step_2/module_tests/test_module_nmap.py b/bbot/test/test_step_2/module_tests/test_module_nmap.py deleted file mode 100644 index 092f84a47..000000000 --- a/bbot/test/test_step_2/module_tests/test_module_nmap.py +++ /dev/null @@ -1,75 +0,0 @@ -from .base import ModuleTestBase - - -class TestNmap(ModuleTestBase): - targets = ["127.0.0.1/31"] - config_overrides = {"modules": {"nmap": {"ports": "8888,8889"}}} - - async def setup_after_prep(self, module_test): - # make sure our IP_RANGE / IP_ADDRESS filtering is working right - # IPs within the target IP range should be rejected - ip_event_1 = module_test.scan.make_event("127.0.0.0", source=module_test.scan.root_event) - ip_event_1.scope_distance = 0 - ip_event_1_result = await module_test.module._event_postcheck(ip_event_1) - assert ip_event_1_result[0] == False - assert ( - "it did not meet custom filter criteria: skipping 127.0.0.0 because it is already included in 127.0.0.0/31" - in ip_event_1_result[1] - ) - # but ones outside should be accepted - ip_event_2 = module_test.scan.make_event("127.0.0.3", source=module_test.scan.root_event) - ip_event_2.scope_distance = 0 - assert (await module_test.module._event_postcheck(ip_event_2))[0] == True - - def check(self, module_test, events): - assert 1 == len([e for e in events if e.data == "127.0.0.1:8888"]) - assert not any(e.data == "127.0.0.1:8889" for e in events) - - -class TestNmapAssetInventory(ModuleTestBase): - targets = ["127.0.0.1/31"] - config_overrides = { - "modules": {"nmap": {"ports": "8888,8889"}}, - "output_modules": {"asset_inventory": {"use_previous": True}}, - } - modules_overrides = ["nmap", "asset_inventory"] - module_name = "nmap" - scan_name = "nmap_test_asset_inventory" - - async def setup_after_prep(self, module_test): - from bbot.scanner import Scanner - - first_scan_config = module_test.scan.config.copy() - first_scan_config["output_modules"]["asset_inventory"]["use_previous"] = False - first_scan = Scanner("127.0.0.1", name=self.scan_name, modules=["asset_inventory"], config=first_scan_config) - await first_scan.async_start_without_generator() - - asset_inventory_output_file = first_scan.home / "asset-inventory.csv" - assert "127.0.0.1," in open(asset_inventory_output_file).read() - # make sure our IP_RANGE / IP_ADDRESS filtering is working right - # IPs within the target IP range should not be rejected because asset_inventory.use_previous=true - ip_event_1 = module_test.scan.make_event("127.0.0.0", source=module_test.scan.root_event) - ip_event_1.scope_distance = 0 - assert (await module_test.module._event_postcheck(ip_event_1))[0] == True - # but ones outside should be accepted - ip_event_2 = module_test.scan.make_event("127.0.0.3", source=module_test.scan.root_event) - ip_event_2.scope_distance = 0 - assert (await module_test.module._event_postcheck(ip_event_2))[0] == True - - ip_range_event = module_test.scan.make_event("127.0.0.1/31", source=module_test.scan.root_event) - ip_range_event.scope_distance = 0 - ip_range_filter_result = await module_test.module._event_postcheck(ip_range_event) - assert ip_range_filter_result[0] == False - assert f"skipping IP_RANGE 127.0.0.0/31 because asset_inventory.use_previous=True" in ip_range_filter_result[1] - - def check(self, module_test, events): - assert 1 == len( - [ - e - for e in events - if e.data == "127.0.0.1:8888" - and e.source.data == "127.0.0.1" - and str(e.source.module) == "asset_inventory" - ] - ) - assert not any(e.data == "127.0.0.1:8889" for e in events) 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 a2ddd8975..fe511c9b6 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 @@ -5,8 +5,10 @@ class TestNucleiManual(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["httpx", "excavate", "nuclei"] config_overrides = { - "web_spider_distance": 1, - "web_spider_depth": 1, + "web": { + "spider_distance": 1, + "spider_depth": 1, + }, "modules": { "nuclei": { "version": "2.9.4", @@ -152,3 +154,31 @@ class TestNucleiRetriesCustom(TestNucleiRetries): def check(self, module_test, events): with open(module_test.scan.home / "debug.log") as f: assert "-retries 1" in f.read() + + +class TestNucleiCustomHeaders(TestNucleiManual): + custom_headers = {"testheader1": "test1", "testheader2": "test2"} + config_overrides = TestNucleiManual.config_overrides + config_overrides["web"]["http_headers"] = custom_headers + + async def setup_after_prep(self, module_test): + expect_args = {"method": "GET", "uri": "/", "headers": self.custom_headers} + respond_args = {"response_data": self.test_html} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + expect_args = {"method": "GET", "uri": "/testmultipleruns.html", "headers": {"nonexistent": "nope"}} + respond_args = {"response_data": "Copyright 1984"} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + def check(self, module_test, events): + first_run_detect = False + second_run_detect = False + for e in events: + if e.type == "FINDING": + if "Directory listing enabled" in e.data["description"]: + first_run_detect = True + elif "Copyright" in e.data["description"]: + second_run_detect = True + # we should find the first one because it requires our custom headers + assert first_run_detect + # the second one requires different headers, so we shouldn't find it + assert not second_run_detect diff --git a/bbot/test/test_step_2/module_tests/test_module_oauth.py b/bbot/test/test_step_2/module_tests/test_module_oauth.py index eac2db2ac..85fe4f917 100644 --- a/bbot/test/test_step_2/module_tests/test_module_oauth.py +++ b/bbot/test/test_step_2/module_tests/test_module_oauth.py @@ -5,7 +5,7 @@ class TestOAUTH(ModuleTestBase): targets = ["evilcorp.com"] - config_overrides = {"scope_report_distance": 1, "omit_event_types": []} + config_overrides = {"scope": {"report_distance": 1}, "omit_event_types": []} modules_overrides = ["azure_realm", "oauth"] openid_config_azure = { "token_endpoint": "https://login.windows.net/cc74fc12-4142-400e-a653-f98bdeadbeef/oauth2/token", @@ -165,6 +165,7 @@ class TestOAUTH(ModuleTestBase): } async def setup_after_prep(self, module_test): + await module_test.mock_dns({"evilcorp.com": {"A": ["127.0.0.1"]}}) module_test.httpx_mock.add_response( url=f"https://login.microsoftonline.com/getuserrealm.srf?login=test@evilcorp.com", json=Azure_Realm.response_json, diff --git a/bbot/test/test_step_2/module_tests/test_module_paramminer_cookies.py b/bbot/test/test_step_2/module_tests/test_module_paramminer_cookies.py index 9a8d41533..58d76ff19 100644 --- a/bbot/test/test_step_2/module_tests/test_module_paramminer_cookies.py +++ b/bbot/test/test_step_2/module_tests/test_module_paramminer_cookies.py @@ -36,15 +36,20 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests(respond_args=respond_args) def check(self, module_test, events): - assert any( - e.type == "FINDING" - and "[Paramminer] Cookie: [admincookie] Reasons: [body] Reflection: [True]" in e.data["description"] - for e in events - ) - assert not any( - e.type == "FINDING" and "[Paramminer] Cookie: [junkcookie] Reasons: [body]" in e.data["description"] - for e in events - ) + + found_reflected_cookie = False + false_positive_match = False + + for e in events: + if e.type == "WEB_PARAMETER": + if "[Paramminer] Cookie: [admincookie] Reasons: [body] Reflection: [True]" in e.data["description"]: + found_reflected_cookie = True + + if "junkcookie" in e.data["description"]: + false_positive_match = True + + assert found_reflected_cookie, "Failed to find hidden reflected cookie parameter" + assert not false_positive_match, "Found word which was in wordlist but not a real match" class TestParamminer_Cookies_noreflection(TestParamminer_Cookies): @@ -59,7 +64,7 @@ class TestParamminer_Cookies_noreflection(TestParamminer_Cookies): def check(self, module_test, events): assert any( - e.type == "FINDING" + e.type == "WEB_PARAMETER" and "[Paramminer] Cookie: [admincookie] Reasons: [body] Reflection: [False]" in e.data["description"] for e in events ) diff --git a/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py b/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py index fa9a4cc20..1bf290c41 100644 --- a/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py +++ b/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py @@ -37,12 +37,12 @@ async def setup_after_prep(self, module_test): def check(self, module_test, events): assert any( - e.type == "FINDING" + e.type == "WEB_PARAMETER" and "[Paramminer] Getparam: [id] Reasons: [body] Reflection: [True]" in e.data["description"] for e in events ) assert not any( - e.type == "FINDING" and "[Paramminer] Getparam: [canary] Reasons: [body]" in e.data["description"] + e.type == "WEB_PARAMETER" and "[Paramminer] Getparam: [canary] Reasons: [body]" in e.data["description"] for e in events ) @@ -59,7 +59,7 @@ class TestParamminer_Getparams_noreflection(TestParamminer_Getparams): def check(self, module_test, events): assert any( - e.type == "FINDING" + e.type == "WEB_PARAMETER" and "[Paramminer] Getparam: [id] Reasons: [body] Reflection: [False]" in e.data["description"] for e in events ) @@ -72,17 +72,16 @@ class TestParamminer_Getparams_singlewordlist(TestParamminer_Getparams): class TestParamminer_Getparams_boring_off(TestParamminer_Getparams): config_overrides = { "modules": { - "paramminer_getparams": {"skip_boring_words": False, "wordlist": tempwordlist(["canary", "boring"])} + "paramminer_getparams": {"skip_boring_words": False, "wordlist": tempwordlist(["canary", "utm_term"])} } } async def setup_after_prep(self, module_test): module_test.scan.modules["paramminer_getparams"].rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" - module_test.scan.modules["paramminer_getparams"].boring_words = {"boring"} module_test.monkeypatch.setattr( helper.HttpCompare, "gen_cache_buster", lambda *args, **kwargs: {"AAAAAA": "1"} ) - expect_args = {"query_string": b"boring=AAAAAAAAAAAAAA&AAAAAA=1"} + expect_args = {"query_string": b"utm_term=AAAAAAAAAAAAAA&AAAAAA=1"} respond_args = {"response_data": self.getparam_body_match} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) @@ -90,10 +89,13 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests(respond_args=respond_args) def check(self, module_test, events): - assert any( - e.type == "FINDING" and "[Paramminer] Getparam: [boring] Reasons: [body]" in e.data["description"] - for e in events - ) + + emitted_boring_parameter = False + for e in events: + if e.type == "WEB_PARAMETER": + if "utm_term" in e.data["description"]: + emitted_boring_parameter = True + assert emitted_boring_parameter, "failed to emit boring parameter with skip_boring_words disabled" class TestParamminer_Getparams_boring_on(TestParamminer_Getparams_boring_off): @@ -104,74 +106,35 @@ class TestParamminer_Getparams_boring_on(TestParamminer_Getparams_boring_off): } def check(self, module_test, events): - assert not any( - e.type == "FINDING" and "[Paramminer] Getparam: [boring] Reasons: [body]" in e.data["description"] - for e in events - ) - - -class TestParamminer_Getparams_Extract_Json(Paramminer_Headers): - modules_overrides = ["httpx", "paramminer_getparams"] - config_overrides = {"modules": {"paramminer_getparams": {"wordlist": tempwordlist([]), "http_extract": True}}} - - getparam_extract_json = """ - { - "obscureParameter": 1, - "common": 1 -} - """ - - getparam_extract_json_match = """ - { - "obscureParameter": "AAAAAAAAAAAAAA", - "common": 1 -} - """ - async def setup_after_prep(self, module_test): - module_test.scan.modules["paramminer_getparams"].rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" - module_test.monkeypatch.setattr( - helper.HttpCompare, "gen_cache_buster", lambda *args, **kwargs: {"AAAAAA": "1"} - ) - - expect_args = {"query_string": b"obscureParameter=AAAAAAAAAAAAAA&AAAAAA=1"} - respond_args = { - "response_data": self.getparam_extract_json_match, - "headers": {"Content-Type": "application/json"}, - } - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + emitted_boring_parameter = False - respond_args = {"response_data": self.getparam_extract_json, "headers": {"Content-Type": "application/json"}} - module_test.set_expect_requests(respond_args=respond_args) + for e in events: + if e.type == "WEB_PARAMETER": + if "boring" in e.data["description"]: + emitted_boring_parameter = True - def check(self, module_test, events): - assert any( - e.type == "FINDING" - and "[Paramminer] Getparam: [obscureParameter] Reasons: [body]" in e.data["description"] - for e in events - ) + assert not emitted_boring_parameter, "emitted boring parameter with skip_boring_words enabled" -class TestParamminer_Getparams_Extract_Xml(Paramminer_Headers): - modules_overrides = ["httpx", "paramminer_getparams"] +class TestParamminer_Getparams_finish(Paramminer_Headers): + modules_overrides = ["httpx", "excavate", "paramminer_getparams"] config_overrides = { - "modules": { - "paramminer_getparams": {"wordlist": tempwordlist([]), "http_extract": True, "skip_boring_words": True} - } + "modules": {"paramminer_getparams": {"wordlist": tempwordlist(["canary", "canary2"]), "recycle_words": True}} } - getparam_extract_xml = """ - - 1 - 1 - + targets = ["http://127.0.0.1:8888/test1.php", "http://127.0.0.1:8888/test2.php"] + + test_1_html = """ +paramstest2 + """ + + test_2_html = """ +

Hello

""" - getparam_extract_xml_match = """ - - AAAAAAAAAAAAAA - 1 - + test_2_html_match = """ +

HackThePlanet!

""" async def setup_after_prep(self, module_test): @@ -179,104 +142,109 @@ async def setup_after_prep(self, module_test): module_test.monkeypatch.setattr( helper.HttpCompare, "gen_cache_buster", lambda *args, **kwargs: {"AAAAAA": "1"} ) - module_test.scan.modules["paramminer_getparams"].boring_words = {"data", "common"} - expect_args = {"query_string": b"obscureParameter=AAAAAAAAAAAAAA&AAAAAA=1"} - respond_args = { - "response_data": self.getparam_extract_xml_match, - "headers": {"Content-Type": "application/xml"}, - } + expect_args = {"uri": "/test2.php", "query_string": b"abcd1234=AAAAAAAAAAAAAA&AAAAAA=1"} + respond_args = {"response_data": self.test_2_html_match} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) - respond_args = {"response_data": self.getparam_extract_xml, "headers": {"Content-Type": "application/xml"}} - module_test.set_expect_requests(respond_args=respond_args) + expect_args = {"uri": "/test2.php"} + respond_args = {"response_data": self.test_2_html} + 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 "[Paramminer] Getparam: [obscureParameter] Reasons: [body]" in e.data["description"] - for e in events - ) + expect_args = {"uri": "/test1.php", "query_string": b"abcd1234=AAAAAAAAAAAAAA&AAAAAA=1"} + respond_args = {"response_data": self.test_2_html_match} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + expect_args = {"uri": "/test1.php"} + respond_args = {"response_data": self.test_1_html} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) -class TestParamminer_Getparams_Extract_Html(Paramminer_Headers): - modules_overrides = ["httpx", "paramminer_getparams"] - config_overrides = { - "modules": {"paramminer_getparams": {"wordlist": tempwordlist(["canary"]), "http_extract": True}} - } + def check(self, module_test, events): - getparam_extract_html = """ -ping - """ + excavate_extracted_web_parameter = False + found_hidden_getparam_recycled = False + emitted_excavate_paramminer_duplicate = False - getparam_extract_html_match = """ -ping

HackThePlanet

- """ + for e in events: - async def setup_after_prep(self, module_test): - module_test.scan.modules["paramminer_getparams"].rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" - module_test.monkeypatch.setattr( - helper.HttpCompare, "gen_cache_buster", lambda *args, **kwargs: {"AAAAAA": "1"} - ) + if e.type == "WEB_PARAMETER": - expect_args = {"query_string": b"id=AAAAAAAAAAAAAA&hack=AAAAAAAAAAAAAA&AAAAAA=1"} - respond_args = {"response_data": self.getparam_extract_html_match, "headers": {"Content-Type": "text/html"}} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + if ( + "http://127.0.0.1:8888/test2.php" in e.data["url"] + and "HTTP Extracted Parameter [abcd1234] (HTML Tags Submodule)" in e.data["description"] + ): + excavate_extracted_web_parameter = True - expect_args = {"query_string": b"hack=AAAAAAAAAAAAAA&AAAAAA=1"} - respond_args = {"response_data": self.getparam_extract_html_match, "headers": {"Content-Type": "text/html"}} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + if ( + "http://127.0.0.1:8888/test1.php" in e.data["url"] + and "[Paramminer] Getparam: [abcd1234] Reasons: [body] Reflection: [False]" + in e.data["description"] + ): + found_hidden_getparam_recycled = True - respond_args = {"response_data": self.getparam_extract_html, "headers": {"Content-Type": "text/html"}} - module_test.set_expect_requests(respond_args=respond_args) + if ( + "http://127.0.0.1:8888/test2.php" in e.data["url"] + and "[Paramminer] Getparam: [abcd1234] Reasons: [body] Reflection: [False]" + in e.data["description"] + ): + emitted_excavate_paramminer_duplicate = True - def check(self, module_test, events): - assert any( - e.type == "FINDING" and "[Paramminer] Getparam: [hack] Reasons: [body]" in e.data["description"] - for e in events - ) + assert excavate_extracted_web_parameter, "Excavate failed to extract GET parameter" + assert found_hidden_getparam_recycled, "Failed to find hidden GET parameter" + # the fact that it is a duplicate is OK, because it still won't be consumed mutltiple times. But we do want to make sure both modules try to emit it + assert emitted_excavate_paramminer_duplicate, "Paramminer emitted duplicate already found by excavate" -class TestParamminer_Getparams_finish(Paramminer_Headers): +class TestParamminer_Getparams_xmlspeculative(Paramminer_Headers): + targets = ["http://127.0.0.1:8888/"] modules_overrides = ["httpx", "excavate", "paramminer_getparams"] - config_overrides = { - "modules": {"paramminer_getparams": {"wordlist": tempwordlist(["canary", "canary2"]), "http_extract": True}} - } - - targets = ["http://127.0.0.1:8888/test1.php", "http://127.0.0.1:8888/test2.php"] - - test_1_html = """ -paramstest2 - """ - - test_2_html = """ -

Hello

+ config_overrides = {"modules": {"paramminer_getparams": {"wordlist": tempwordlist([]), "recycle_words": False}}} + getparam_extract_xml = """ + + 1 + 1 + """ - test_2_html_match = """ -

HackThePlanet!

+ getparam_speculative_used = """ + +

secret parameter used

+ """ async def setup_after_prep(self, module_test): + module_test.scan.modules["paramminer_getparams"].rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" module_test.monkeypatch.setattr( helper.HttpCompare, "gen_cache_buster", lambda *args, **kwargs: {"AAAAAA": "1"} ) - - expect_args = {"uri": "/test2.php", "query_string": b"abcd1234=AAAAAAAAAAAAAA&AAAAAA=1"} - respond_args = {"response_data": self.test_2_html_match} + expect_args = {"query_string": b"obscureParameter=AAAAAAAAAAAAAA&AAAAAA=1"} + respond_args = {"response_data": self.getparam_speculative_used} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) - expect_args = {"uri": "/test2.php"} - respond_args = {"response_data": self.test_2_html} + expect_args = {"query_string": b"data=AAAAAAAAAAAAAA&obscureParameter=AAAAAAAAAAAAAA&AAAAAA=1"} + respond_args = {"response_data": self.getparam_speculative_used} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) - expect_args = {"uri": "/test1.php"} - respond_args = {"response_data": self.test_1_html} - module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + respond_args = {"response_data": self.getparam_extract_xml, "headers": {"Content-Type": "application/xml"}} + module_test.set_expect_requests(respond_args=respond_args) def check(self, module_test, events): - assert any( - e.type == "FINDING" and "[abcd1234] Reasons: [body] Reflection: [False]" in e.data["description"] - for e in events - ) + excavate_discovered_speculative = False + paramminer_used_speculative = False + for e in events: + if e.type == "WEB_PARAMETER": + if ( + "HTTP Extracted Parameter (speculative from xml content) [obscureParameter]" + in e.data["description"] + ): + excavate_discovered_speculative = True + + if ( + "[Paramminer] Getparam: [obscureParameter] Reasons: [header,body] Reflection: [False]" + in e.data["description"] + ): + paramminer_used_speculative = True + + assert excavate_discovered_speculative, "Excavate failed to discover speculative xml parameter" + assert paramminer_used_speculative, "Paramminer failed to confirm speculative GET parameter" diff --git a/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py b/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py index 7cc8114e6..0f66e5e87 100644 --- a/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py +++ b/bbot/test/test_step_2/module_tests/test_module_paramminer_headers.py @@ -39,14 +39,20 @@ async def setup_after_prep(self, module_test): module_test.set_expect_requests(respond_args=respond_args) def check(self, module_test, events): - assert any( - e.type == "FINDING" - and "[Paramminer] Header: [tracestate] Reasons: [body] Reflection: [True]" in e.data["description"] - for e in events - ) - assert not any( - e.type == "FINDING" and "[Paramminer] Header: [junkword1]" in e.data["description"] for e in events - ) + + found_reflected_header = False + false_positive_match = False + + for e in events: + if e.type == "WEB_PARAMETER": + if "[Paramminer] Header: [tracestate] Reasons: [body] Reflection: [True]" in e.data["description"]: + found_reflected_header = True + + if "junkword1" in e.data["description"]: + false_positive_match = True + + assert found_reflected_header, "Failed to find hidden reflected header parameter" + assert not false_positive_match, "Found word which was in wordlist but not a real match" class TestParamminer_Headers(Paramminer_Headers): @@ -54,6 +60,9 @@ class TestParamminer_Headers(Paramminer_Headers): class TestParamminer_Headers_noreflection(Paramminer_Headers): + + found_nonreflected_header = False + headers_body_match = """ the title @@ -64,8 +73,90 @@ class TestParamminer_Headers_noreflection(Paramminer_Headers): """ def check(self, module_test, events): - assert any( - e.type == "FINDING" - and "[Paramminer] Header: [tracestate] Reasons: [body] Reflection: [False]" in e.data["description"] - for e in events + for e in events: + if e.type == "WEB_PARAMETER": + if "[Paramminer] Header: [tracestate] Reasons: [body] Reflection: [False]" in e.data["description"]: + found_nonreflected_header = True + + assert found_nonreflected_header, "Failed to find hidden non-reflected header parameter" + + +class TestParamminer_Headers_extract(Paramminer_Headers): + + modules_overrides = ["httpx", "paramminer_headers", "excavate"] + config_overrides = { + "modules": { + "paramminer_headers": {"wordlist": tempwordlist(["junkword1", "tracestate"]), "recycle_words": True} + } + } + + headers_body = """ + + the title + + Click Me + + + """ + + headers_body_match = """ + + the title + + Click Me + Click Me +

Secret param "foo" found with value: AAAAAAAAAAAAAA

+ + + """ + + async def setup_after_prep(self, module_test): + module_test.scan.modules["paramminer_headers"].rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.monkeypatch.setattr( + helper.HttpCompare, "gen_cache_buster", lambda *args, **kwargs: {"AAAAAA": "1"} ) + expect_args = dict(headers={"foo": "AAAAAAAAAAAAAA"}) + respond_args = {"response_data": self.headers_body_match} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + respond_args = {"response_data": self.headers_body} + module_test.set_expect_requests(respond_args=respond_args) + + def check(self, module_test, events): + + excavate_extracted_web_parameter = False + used_recycled_parameter = False + + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [foo] (HTML Tags Submodule)" in e.data["description"]: + excavate_extracted_web_parameter = True + if "[Paramminer] Header: [foo] Reasons: [body] Reflection: [True]" in e.data["description"]: + used_recycled_parameter = True + + assert excavate_extracted_web_parameter, "Excavate failed to extract WEB_PARAMETER" + assert used_recycled_parameter, "Failed to find header with recycled parameter" + + +class TestParamminer_Headers_extract_norecycle(TestParamminer_Headers_extract): + + modules_overrides = ["httpx", "excavate"] + config_overrides = {} + + async def setup_after_prep(self, module_test): + + respond_args = {"response_data": self.headers_body} + module_test.set_expect_requests(respond_args=respond_args) + + def check(self, module_test, events): + + excavate_extracted_web_parameter = False + + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [foo] (HTML Tags Submodule)" in e.data["description"]: + excavate_extracted_web_parameter = True + + assert ( + not excavate_extracted_web_parameter + ), "Excavate extract WEB_PARAMETER despite disabling parameter extraction" 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 new file mode 100644 index 000000000..56536cb5d --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_portscan.py @@ -0,0 +1,156 @@ +from .base import ModuleTestBase + + +class TestPortscan(ModuleTestBase): + targets = [ + "www.evilcorp.com", + "evilcorp.com", + "8.8.8.8/32", + "8.8.8.8/24", + "8.8.4.4", + "asdf.evilcorp.net", + "8.8.4.4/24", + ] + scan_name = "test_portscan" + config_overrides = {"modules": {"portscan": {"ports": "443", "wait": 1}}, "dns": {"minimal": False}} + + masscan_output_1 = """{ "ip": "8.8.8.8", "timestamp": "1680197558", "ports": [ {"port": 443, "proto": "tcp", "status": "open", "reason": "syn-ack", "ttl": 54} ] }""" + masscan_output_2 = """{ "ip": "8.8.4.5", "timestamp": "1680197558", "ports": [ {"port": 80, "proto": "tcp", "status": "open", "reason": "syn-ack", "ttl": 54} ] }""" + masscan_output_3 = """{ "ip": "8.8.4.6", "timestamp": "1680197558", "ports": [ {"port": 631, "proto": "tcp", "status": "open", "reason": "syn-ack", "ttl": 54} ] }""" + + masscan_output_ping = """{ "ip": "8.8.8.8", "timestamp": "1719862594", "ports": [ {"port": 0, "proto": "icmp", "status": "open", "reason": "none", "ttl": 54} ] }""" + + async def setup_after_prep(self, module_test): + + from bbot.modules.base import BaseModule + + class DummyModule(BaseModule): + _name = "dummy_module" + watched_events = ["*"] + + async def handle_event(self, event): + if event.type == "DNS_NAME": + if "dummy" not in event.host: + await self.emit_event(f"dummy.{event.data}", "DNS_NAME", parent=event) + + module_test.scan.modules["dummy_module"] = DummyModule(module_test.scan) + + await module_test.mock_dns( + { + "www.evilcorp.com": {"A": ["8.8.8.8"]}, + "evilcorp.com": {"A": ["8.8.8.8"]}, + "asdf.evilcorp.net": {"A": ["8.8.4.5"]}, + "dummy.asdf.evilcorp.net": {"A": ["8.8.4.5"]}, + "dummy.evilcorp.com": {"A": ["8.8.4.6"]}, + "dummy.www.evilcorp.com": {"A": ["8.8.4.4"]}, + } + ) + + self.syn_scanned = [] + self.ping_scanned = [] + self.syn_runs = 0 + self.ping_runs = 0 + + async def run_masscan(command, *args, **kwargs): + if "masscan" in command[:2]: + targets = open(command[11]).read().splitlines() + yield "[" + if "--ping" in command: + self.ping_runs += 1 + self.ping_scanned += targets + yield self.masscan_output_ping + else: + self.syn_runs += 1 + self.syn_scanned += targets + if "8.8.8.0/24" in targets or "8.8.8.8/32" in targets: + yield self.masscan_output_1 + if "8.8.4.0/24" in targets: + yield self.masscan_output_2 + yield self.masscan_output_3 + yield "]" + else: + async for l in module_test.scan.helpers.run_live(command, *args, **kwargs): + yield l + + module_test.monkeypatch.setattr(module_test.scan.helpers, "run_live", run_masscan) + + def check(self, module_test, events): + assert set(self.syn_scanned) == {"8.8.8.0/24", "8.8.4.0/24"} + assert set(self.ping_scanned) == set() + 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"] + ) + assert 1 == len( + [e for e in events if e.type == "DNS_NAME" and e.data == "www.evilcorp.com" and str(e.module) == "TARGET"] + ) + assert 1 == len( + [e for e in events if e.type == "DNS_NAME" and e.data == "asdf.evilcorp.net" and str(e.module) == "TARGET"] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" and e.data == "dummy.evilcorp.com" and str(e.module) == "dummy_module" + ] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" and e.data == "dummy.www.evilcorp.com" and str(e.module) == "dummy_module" + ] + ) + assert 1 == len( + [ + e + for e in events + if e.type == "DNS_NAME" and e.data == "dummy.asdf.evilcorp.net" and str(e.module) == "dummy_module" + ] + ) + assert 2 <= len([e for e in events if e.type == "IP_ADDRESS" and e.data == "8.8.8.8"]) <= 3 + assert 2 <= len([e for e in events if e.type == "IP_ADDRESS" and e.data == "8.8.4.4"]) <= 3 + assert 2 <= len([e for e in events if e.type == "IP_ADDRESS" and e.data == "8.8.4.5"]) <= 3 + assert 2 <= len([e for e in events if e.type == "IP_ADDRESS" and e.data == "8.8.4.6"]) <= 3 + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "8.8.8.8:443"]) + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "8.8.4.5:80"]) + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "8.8.4.6:631"]) + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "evilcorp.com:443"]) + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "www.evilcorp.com:443"]) + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "asdf.evilcorp.net:80"]) + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "dummy.asdf.evilcorp.net:80"]) + assert 1 == len([e for e in events if e.type == "OPEN_TCP_PORT" and e.data == "dummy.evilcorp.com:631"]) + assert not any([e for e in events if e.type == "OPEN_TCP_PORT" and e.host == "dummy.www.evilcorp.com"]) + + +class TestPortscanPingFirst(TestPortscan): + modules_overrides = {"portscan"} + config_overrides = {"modules": {"portscan": {"ports": "443", "wait": 1, "ping_first": True}}} + + def check(self, module_test, events): + assert set(self.syn_scanned) == {"8.8.8.8/32"} + assert set(self.ping_scanned) == {"8.8.8.0/24", "8.8.4.0/24"} + assert self.syn_runs == 1 + assert self.ping_runs == 1 + open_port_events = [e for e in events if e.type == "OPEN_TCP_PORT"] + assert len(open_port_events) == 3 + assert set([e.data for e in open_port_events]) == {"8.8.8.8:443", "evilcorp.com:443", "www.evilcorp.com:443"} + + +class TestPortscanPingOnly(TestPortscan): + modules_overrides = {"portscan"} + config_overrides = {"modules": {"portscan": {"ports": "443", "wait": 1, "ping_only": True}}} + + targets = ["8.8.8.8/24", "8.8.4.4/24"] + + def check(self, module_test, events): + assert set(self.syn_scanned) == set() + assert set(self.ping_scanned) == {"8.8.8.0/24", "8.8.4.0/24"} + assert self.syn_runs == 0 + assert self.ping_runs == 1 + open_port_events = [e for e in events if e.type == "OPEN_TCP_PORT"] + assert len(open_port_events) == 0 + ip_events = [e for e in events if e.type == "IP_ADDRESS"] + assert len(ip_events) == 1 + assert set([e.data for e in ip_events]) == {"8.8.8.8"} diff --git a/bbot/test/test_step_2/module_tests/test_module_postman.py b/bbot/test/test_step_2/module_tests/test_module_postman.py index 21f464054..5fc09b1a1 100644 --- a/bbot/test/test_step_2/module_tests/test_module_postman.py +++ b/bbot/test/test_step_2/module_tests/test_module_postman.py @@ -4,7 +4,7 @@ class TestPostman(ModuleTestBase): config_overrides = { "omit_event_types": [], - "scope_report_distance": 1, + "scope": {"report_distance": 1}, } modules_overrides = ["postman", "httpx", "excavate"] @@ -235,7 +235,9 @@ async def new_emit_event(event_data, event_type, **kwargs): await old_emit_event(event_data, event_type, **kwargs) module_test.monkeypatch.setattr(module_test.module, "emit_event", new_emit_event) - module_test.mock_dns({"asdf.blacklanternsecurity.com": {"A": ["127.0.0.1"]}}) + await module_test.mock_dns( + {"blacklanternsecurity.com": {"A": ["127.0.0.1"]}, "asdf.blacklanternsecurity.com": {"A": ["127.0.0.1"]}} + ) request_args = dict(uri="/_api/request/28129865-987c8ac8-bfa9-4bab-ade9-88ccf0597862") respond_args = dict(response_data="https://asdf.blacklanternsecurity.com") diff --git a/bbot/test/test_step_2/module_tests/test_module_sitedossier.py b/bbot/test/test_step_2/module_tests/test_module_sitedossier.py index 2156a5ae7..a5b57b800 100644 --- a/bbot/test/test_step_2/module_tests/test_module_sitedossier.py +++ b/bbot/test/test_step_2/module_tests/test_module_sitedossier.py @@ -126,6 +126,15 @@ class TestSitedossier(ModuleTestBase): targets = ["evilcorp.com"] async def setup_after_prep(self, module_test): + await module_test.mock_dns( + { + "evilcorp.com": {"A": ["127.0.0.1"]}, + "asdf.evilcorp.com": {"A": ["127.0.0.1"]}, + "zzzz.evilcorp.com": {"A": ["127.0.0.1"]}, + "xxxx.evilcorp.com": {"A": ["127.0.0.1"]}, + "ffff.evilcorp.com": {"A": ["127.0.0.1"]}, + } + ) module_test.httpx_mock.add_response( url=f"http://www.sitedossier.com/parentdomain/evilcorp.com", text=page1, 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 b486d7df2..1258ed511 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 @@ -2,8 +2,6 @@ class TestSlack(DiscordBase): - targets = ["http://127.0.0.1:8888/cookie.aspx", "http://127.0.0.1:8888/cookie2.aspx"] modules_overrides = ["slack", "excavate", "badsecrets", "httpx"] - webhook_url = "https://hooks.slack.com/services/deadbeef/deadbeef/deadbeef" - config_overrides = {"output_modules": {"slack": {"webhook_url": webhook_url}}} + config_overrides = {"modules": {"slack": {"webhook_url": webhook_url}}} 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 2dcafaddc..8b6150919 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 @@ -28,7 +28,7 @@ class TestSpeculate_OpenPorts(ModuleTestBase): config_overrides = {"speculate": True} async def setup_before_prep(self, module_test): - module_test.mock_dns( + await module_test.mock_dns( { "evilcorp.com": {"A": ["127.0.254.1"]}, "asdf.evilcorp.com": {"A": ["127.0.254.2"]}, @@ -71,7 +71,7 @@ def check(self, module_test, events): class TestSpeculate_OpenPorts_Portscanner(TestSpeculate_OpenPorts): targets = ["evilcorp.com"] - modules_overrides = ["speculate", "certspotter", "nmap"] + modules_overrides = ["speculate", "certspotter", "portscan"] config_overrides = {"speculate": True} def check(self, module_test, events): 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 67d67a4ef..d55ed17c2 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 @@ -7,7 +7,7 @@ class TestSplunk(ModuleTestBase): downstream_url = "https://splunk.blacklanternsecurity.fakedomain:1234/services/collector" config_overrides = { - "output_modules": { + "modules": { "splunk": { "url": downstream_url, "hectoken": "HECTOKEN", diff --git a/bbot/test/test_step_2/module_tests/test_module_sslcert.py b/bbot/test/test_step_2/module_tests/test_module_sslcert.py index 1a8796ab5..f8c4948fd 100644 --- a/bbot/test/test_step_2/module_tests/test_module_sslcert.py +++ b/bbot/test/test_step_2/module_tests/test_module_sslcert.py @@ -3,7 +3,7 @@ class TestSSLCert(ModuleTestBase): targets = ["127.0.0.1:9999", "bbottest.notreal"] - config_overrides = {"scope_report_distance": 1} + config_overrides = {"scope": {"report_distance": 1}} def check(self, module_test, events): assert len(events) == 6 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 new file mode 100644 index 000000000..2c9eaf9bd --- /dev/null +++ b/bbot/test/test_step_2/module_tests/test_module_stdout.py @@ -0,0 +1,101 @@ +import json + +from .base import ModuleTestBase + + +class TestStdout(ModuleTestBase): + modules_overrides = ["stdout"] + + 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 + + +class TestStdoutEventTypes(TestStdout): + config_overrides = {"modules": {"stdout": {"event_types": ["DNS_NAME"]}}} + + 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") + + +class TestStdoutEventFields(TestStdout): + config_overrides = {"modules": {"stdout": {"event_types": ["DNS_NAME"], "event_fields": ["data"]}}} + + def check(self, module_test, events): + out, err = module_test.capsys.readouterr() + assert out == "blacklanternsecurity.com\n" + + +class TestStdoutJSON(TestStdout): + config_overrides = { + "modules": { + "stdout": { + "format": "json", + } + } + } + + def check(self, module_test, events): + out, err = module_test.capsys.readouterr() + lines = out.splitlines() + assert len(lines) == 2 + for i, line in enumerate(lines): + event = json.loads(line) + if i == 0: + assert event["type"] == "SCAN" + elif i == 2: + assert event["type"] == "DNS_NAME" and event["data"] == "blacklanternsecurity.com" + + +class TestStdoutJSONFields(TestStdout): + config_overrides = {"modules": {"stdout": {"format": "json", "event_fields": ["data", "module_sequence"]}}} + + def check(self, module_test, events): + out, err = module_test.capsys.readouterr() + lines = out.splitlines() + assert len(lines) == 2 + for line in lines: + event = json.loads(line) + assert set(event) == {"data", "module_sequence"} + + +class TestStdoutDupes(TestStdout): + targets = ["blacklanternsecurity.com", "127.0.0.2"] + config_overrides = { + "dns": {"minimal": False}, + "modules": { + "stdout": { + "event_types": ["DNS_NAME", "IP_ADDRESS"], + } + }, + } + + async def setup_after_prep(self, module_test): + await module_test.mock_dns({"blacklanternsecurity.com": {"A": ["127.0.0.2"]}}) + + def check(self, module_test, events): + out, err = module_test.capsys.readouterr() + lines = out.splitlines() + assert len(lines) == 3 + assert out.count("[IP_ADDRESS] \t127.0.0.2") == 2 + + +class TestStdoutNoDupes(TestStdoutDupes): + config_overrides = { + "dns": {"minimal": False}, + "modules": { + "stdout": { + "event_types": ["DNS_NAME", "IP_ADDRESS"], + "accept_dupes": False, + } + }, + } + + def check(self, module_test, events): + out, err = module_test.capsys.readouterr() + lines = out.splitlines() + assert len(lines) == 2 + assert out.count("[IP_ADDRESS] \t127.0.0.2") == 1 diff --git a/bbot/test/test_step_2/module_tests/test_module_subdomains.py b/bbot/test/test_step_2/module_tests/test_module_subdomains.py index 9aa9f7b5e..65b9a8a03 100644 --- a/bbot/test/test_step_2/module_tests/test_module_subdomains.py +++ b/bbot/test/test_step_2/module_tests/test_module_subdomains.py @@ -17,7 +17,7 @@ def check(self, module_test, events): class TestSubdomainsUnresolved(TestSubdomains): - config_overrides = {"output_modules": {"subdomains": {"include_unresolved": True}}} + config_overrides = {"modules": {"subdomains": {"include_unresolved": True}}} def check(self, module_test, events): sub_file = module_test.scan.home / "subdomains.txt" 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 f544f5cb9..c53c5c75d 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 @@ -4,11 +4,10 @@ class TestTeams(DiscordBase): - targets = ["http://127.0.0.1:8888/cookie.aspx", "http://127.0.0.1:8888/cookie2.aspx"] modules_overrides = ["teams", "excavate", "badsecrets", "httpx"] webhook_url = "https://evilcorp.webhook.office.com/webhookb2/deadbeef@deadbeef/IncomingWebhook/deadbeef/deadbeef" - config_overrides = {"output_modules": {"teams": {"webhook_url": webhook_url}}} + config_overrides = {"modules": {"teams": {"webhook_url": webhook_url}}} async def setup_after_prep(self, module_test): self.custom_setup(module_test) 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 4d7122276..9cd5be601 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 @@ -854,18 +854,38 @@ def check(self, module_test, events): and "Secret: [https://admin:admin@the-internet.herokuapp.com]" in e.data["description"] ] assert 3 == len(vuln_events), "Failed to find secret in events" - github_repo_event = [e for e in vuln_events if "test_keys" in e.data["description"]][0].source + github_repo_event = [e for e in vuln_events if "test_keys" in e.data["description"]][0].parent folder = Path(github_repo_event.data["path"]) assert folder.is_dir(), "Destination folder doesn't exist" with open(folder / "keys.txt") as f: content = f.read() assert content == self.file_content, "File content doesn't match" - github_workflow_event = [e for e in vuln_events if "bbot" in e.data["description"]][0].source - file = Path(github_workflow_event.data["path"]) - assert file.is_file(), "Destination file does not exist" - docker_source_event = [e for e in vuln_events if e.data["host"] == "hub.docker.com"][0].source - file = Path(docker_source_event.data["path"]) - assert file.is_file(), "Destination image does not exist" + filesystem_events = [e.parent for e in vuln_events if "bbot" in e.data["description"]] + assert len(filesystem_events) == 3 + assert all([e.type == "FILESYSTEM" for e in filesystem_events]) + assert 1 == len( + [ + e + for e in filesystem_events + if e.data["path"].endswith("/git_repos/test_keys") and Path(e.data["path"]).is_dir() + ] + ), "Test keys repo dir does not exist" + assert 1 == len( + [ + e + for e in filesystem_events + if e.data["path"].endswith("/workflow_logs/blacklanternsecurity/bbot/test.txt") + and Path(e.data["path"]).is_file() + ] + ), "Workflow log file does not exist" + assert 1 == len( + [ + e + for e in filesystem_events + if e.data["path"].endswith("/docker_images/blacklanternsecurity_helloworld_latest.tar") + and Path(e.data["path"]).is_file() + ] + ), "Docker image file does not exist" class TestTrufflehog_NonVerified(TestTrufflehog): @@ -881,15 +901,35 @@ def check(self, module_test, events): and "Secret: [https://admin:admin@internal.host.com]" in e.data["description"] ] assert 3 == len(finding_events), "Failed to find secret in events" - github_repo_event = [e for e in finding_events if "test_keys" in e.data["description"]][0].source + github_repo_event = [e for e in finding_events if "test_keys" in e.data["description"]][0].parent folder = Path(github_repo_event.data["path"]) assert folder.is_dir(), "Destination folder doesn't exist" with open(folder / "keys.txt") as f: content = f.read() assert content == self.file_content, "File content doesn't match" - github_workflow_event = [e for e in finding_events if "bbot" in e.data["description"]][0].source - file = Path(github_workflow_event.data["path"]) - assert file.is_file(), "Destination file does not exist" - docker_source_event = [e for e in finding_events if e.data["host"] == "hub.docker.com"][0].source - file = Path(docker_source_event.data["path"]) - assert file.is_file(), "Destination image does not exist" + filesystem_events = [e.parent for e in finding_events if "bbot" in e.data["description"]] + assert len(filesystem_events) == 3 + assert all([e.type == "FILESYSTEM" for e in filesystem_events]) + assert 1 == len( + [ + e + for e in filesystem_events + if e.data["path"].endswith("/git_repos/test_keys") and Path(e.data["path"]).is_dir() + ] + ), "Test keys repo dir does not exist" + assert 1 == len( + [ + e + for e in filesystem_events + if e.data["path"].endswith("/workflow_logs/blacklanternsecurity/bbot/test.txt") + and Path(e.data["path"]).is_file() + ] + ), "Workflow log file does not exist" + assert 1 == len( + [ + e + for e in filesystem_events + if e.data["path"].endswith("/docker_images/blacklanternsecurity_helloworld_latest.tar") + and Path(e.data["path"]).is_file() + ] + ), "Docker image file does not exist" diff --git a/bbot/test/test_step_2/module_tests/test_module_human.py b/bbot/test/test_step_2/module_tests/test_module_txt.py similarity index 86% rename from bbot/test/test_step_2/module_tests/test_module_human.py rename to bbot/test/test_step_2/module_tests/test_module_txt.py index 8bf252a00..5602c8664 100644 --- a/bbot/test/test_step_2/module_tests/test_module_human.py +++ b/bbot/test/test_step_2/module_tests/test_module_txt.py @@ -1,7 +1,7 @@ from .base import ModuleTestBase -class TestHuman(ModuleTestBase): +class TestTXT(ModuleTestBase): def check(self, module_test, events): txt_file = module_test.scan.home / "output.txt" with open(txt_file) as f: diff --git a/bbot/test/test_step_2/module_tests/test_module_unstructured.py b/bbot/test/test_step_2/module_tests/test_module_unstructured.py index 0c404736b..25ea5b829 100644 --- a/bbot/test/test_step_2/module_tests/test_module_unstructured.py +++ b/bbot/test/test_step_2/module_tests/test_module_unstructured.py @@ -5,7 +5,7 @@ class TestUnstructured(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["unstructured", "filedownload", "httpx", "excavate", "speculate"] - config_overrides = {"web_spider_distance": 2, "web_spider_depth": 2} + config_overrides = {"web": {"spider_distance": 2, "spider_depth": 2}} pdf_data = """%PDF-1.3 %���� ReportLab Generated PDF document http://www.reportlab.com diff --git a/bbot/test/test_step_2/module_tests/test_module_wafw00f.py b/bbot/test/test_step_2/module_tests/test_module_wafw00f.py index 39cb43c13..892d892ff 100644 --- a/bbot/test/test_step_2/module_tests/test_module_wafw00f.py +++ b/bbot/test/test_step_2/module_tests/test_module_wafw00f.py @@ -1,5 +1,7 @@ from .base import ModuleTestBase +from werkzeug.wrappers import Response + class TestWafw00f(ModuleTestBase): targets = ["http://127.0.0.1:8888"] @@ -28,3 +30,21 @@ async def setup_after_prep(self, module_test): def check(self, module_test, events): assert not any(e.type == "WAF" for e in events) + + +class TestWafw00f_genericdetection(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["httpx", "wafw00f"] + + async def setup_after_prep(self, module_test): + def handler(request): + if "SLEEP" in request.url: + return Response("nope", status=403) + return Response("yep") + + module_test.httpserver.expect_request("/").respond_with_handler(handler) + + def check(self, module_test, events): + waf_events = [e for e in events if e.type == "WAF"] + assert len(waf_events) == 1 + assert waf_events[0].data["waf"] == "generic detection" 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 a37c178e2..c34eef00f 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 @@ -13,8 +13,6 @@ async def setup_before_prep(self, module_test): module_test.set_expect_requests(respond_args=respond_args) def check(self, module_test, events): - for e in events: - module_test.log.critical(e) report_file = module_test.scan.home / "web_report.html" with open(report_file) as f: report_content = f.read() diff --git a/bbot/test/test_step_2/module_tests/test_module_websocket.py b/bbot/test/test_step_2/module_tests/test_module_websocket.py index d1620702c..fcf5c2eee 100644 --- a/bbot/test/test_step_2/module_tests/test_module_websocket.py +++ b/bbot/test/test_step_2/module_tests/test_module_websocket.py @@ -23,7 +23,7 @@ async def server_coroutine(): class TestWebsocket(ModuleTestBase): - config_overrides = {"output_modules": {"websocket": {"url": "ws://127.0.0.1:8765/testing"}}} + config_overrides = {"modules": {"websocket": {"url": "ws://127.0.0.1:8765/testing"}}} async def setup_before_prep(self, module_test): self.server_task = asyncio.create_task(server_coroutine()) diff --git a/bbot/test/test_step_2/template_tests/__init__.py b/bbot/test/test_step_2/template_tests/__init__.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..f6cb3b740 --- /dev/null +++ b/bbot/test/test_step_2/template_tests/test_template_subdomain_enum.py @@ -0,0 +1,86 @@ +from ..module_tests.base import ModuleTestBase + + +class TestSubdomainEnum(ModuleTestBase): + targets = ["blacklanternsecurity.com"] + modules_overrides = [] + config_overrides = {"dns": {"minimal": False}, "scope": {"report_distance": 10}} + dedup_strategy = "highest_parent" + + txt = [ + "www.blacklanternsecurity.com", + "asdf.www.blacklanternsecurity.com", + "test.asdf.www.blacklanternsecurity.com", + "api.test.asdf.www.blacklanternsecurity.com", + ] + + async def setup_after_prep(self, module_test): + dns_mock = { + "evilcorp.com": {"A": ["127.0.0.6"]}, + "blacklanternsecurity.com": {"A": ["127.0.0.5"]}, + "www.blacklanternsecurity.com": {"A": ["127.0.0.5"]}, + "asdf.www.blacklanternsecurity.com": {"A": ["127.0.0.5"]}, + "test.asdf.www.blacklanternsecurity.com": {"A": ["127.0.0.5"]}, + "api.test.asdf.www.blacklanternsecurity.com": {"A": ["127.0.0.5"]}, + } + if self.txt: + dns_mock["blacklanternsecurity.com"]["TXT"] = self.txt + await module_test.mock_dns(dns_mock) + + # load subdomain enum template as module + from bbot.modules.templates.subdomain_enum import subdomain_enum + + subdomain_enum_module = subdomain_enum(module_test.scan) + + self.queries = [] + + async def mock_query(query): + self.queries.append(query) + + subdomain_enum_module.query = mock_query + subdomain_enum_module.dedup_strategy = self.dedup_strategy + module_test.scan.modules["subdomain_enum"] = subdomain_enum_module + + def check(self, module_test, events): + in_scope_dns_names = [e for e in events if e.type == "DNS_NAME" and e.scope_distance == 0] + assert len(in_scope_dns_names) == 5 + assert 1 == len([e for e in in_scope_dns_names if e.data == "blacklanternsecurity.com"]) + assert 1 == len([e for e in in_scope_dns_names if e.data == "www.blacklanternsecurity.com"]) + assert 1 == len([e for e in in_scope_dns_names if e.data == "asdf.www.blacklanternsecurity.com"]) + assert 1 == len([e for e in in_scope_dns_names if e.data == "test.asdf.www.blacklanternsecurity.com"]) + assert 1 == len([e for e in in_scope_dns_names if e.data == "api.test.asdf.www.blacklanternsecurity.com"]) + assert len(self.queries) == 1 + assert self.queries[0] == "blacklanternsecurity.com" + + +class TestSubdomainEnumHighestParent(TestSubdomainEnum): + targets = ["api.test.asdf.www.blacklanternsecurity.com", "evilcorp.com"] + whitelist = ["www.blacklanternsecurity.com"] + modules_overrides = ["speculate"] + dedup_strategy = "highest_parent" + txt = None + + def check(self, module_test, events): + in_scope_dns_names = [e for e in events if e.type == "DNS_NAME" and e.scope_distance == 0] + distance_1_dns_names = [e for e in events if e.type == "DNS_NAME" and e.scope_distance == 1] + assert len(in_scope_dns_names) == 4 + assert 1 == len([e for e in in_scope_dns_names if e.data == "www.blacklanternsecurity.com"]) + assert 1 == len([e for e in in_scope_dns_names if e.data == "asdf.www.blacklanternsecurity.com"]) + assert 1 == len([e for e in in_scope_dns_names if e.data == "test.asdf.www.blacklanternsecurity.com"]) + assert 1 == len([e for e in in_scope_dns_names if e.data == "api.test.asdf.www.blacklanternsecurity.com"]) + 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" + + +class TestSubdomainEnumLowestParent(TestSubdomainEnumHighestParent): + dedup_strategy = "lowest_parent" + + def check(self, module_test, events): + assert set(self.queries) == { + "test.asdf.www.blacklanternsecurity.com", + "asdf.www.blacklanternsecurity.com", + "www.blacklanternsecurity.com", + } diff --git a/bbot/wordlists/top_open_ports_nmap.txt b/bbot/wordlists/top_open_ports_nmap.txt new file mode 100644 index 000000000..30f1bcefd --- /dev/null +++ b/bbot/wordlists/top_open_ports_nmap.txt @@ -0,0 +1,8377 @@ +80 +23 +443 +21 +22 +25 +3389 +110 +445 +139 +143 +53 +135 +3306 +8080 +1723 +111 +995 +993 +5900 +1025 +587 +8888 +199 +1720 +465 +548 +113 +81 +6001 +10000 +514 +5060 +179 +1026 +2000 +8443 +8000 +32768 +554 +26 +1433 +49152 +2001 +515 +8008 +49154 +1027 +5666 +646 +5000 +5631 +631 +49153 +8081 +2049 +88 +79 +5800 +106 +2121 +1110 +49155 +6000 +513 +990 +5357 +427 +49156 +543 +544 +5101 +144 +7 +389 +8009 +3128 +444 +9999 +5009 +7070 +5190 +3000 +5432 +1900 +3986 +13 +1029 +9 +5051 +6646 +49157 +1028 +873 +1755 +2717 +4899 +9100 +119 +37 +1000 +3001 +5001 +82 +10010 +1030 +9090 +2107 +1024 +2103 +6004 +1801 +5050 +19 +8031 +1041 +255 +1049 +1048 +2967 +1053 +3703 +1056 +1065 +1064 +1054 +17 +808 +3689 +1031 +1044 +1071 +5901 +100 +9102 +8010 +2869 +1039 +5120 +4001 +9000 +2105 +636 +1038 +2601 +1 +7000 +1066 +1069 +625 +311 +280 +254 +4000 +1761 +5003 +2002 +2005 +1998 +1032 +1050 +6112 +3690 +1521 +2161 +6002 +1080 +2401 +4045 +902 +7937 +787 +1058 +2383 +32771 +1033 +1040 +1059 +50000 +5555 +10001 +1494 +593 +2301 +3 +3268 +7938 +1234 +1022 +1074 +8002 +1036 +1035 +9001 +1037 +464 +497 +1935 +6666 +2003 +6543 +1352 +24 +3269 +1111 +407 +500 +20 +2006 +3260 +15000 +1218 +1034 +4444 +264 +2004 +33 +1042 +42510 +999 +3052 +1023 +1068 +222 +7100 +888 +563 +1717 +2008 +992 +32770 +32772 +7001 +8082 +2007 +5550 +2009 +5801 +1043 +512 +2701 +7019 +50001 +1700 +4662 +2065 +2010 +42 +9535 +2602 +3333 +161 +5100 +5002 +2604 +4002 +6059 +1047 +8192 +8193 +2702 +6789 +9595 +1051 +9594 +9593 +16993 +16992 +5226 +5225 +32769 +3283 +1052 +8194 +1055 +1062 +9415 +8701 +8652 +8651 +8089 +65389 +65000 +64680 +64623 +55600 +55555 +52869 +35500 +33354 +23502 +20828 +1311 +1060 +4443 +1067 +13782 +5902 +366 +9050 +1002 +85 +5500 +5431 +1864 +1863 +8085 +51103 +49999 +45100 +10243 +49 +6667 +90 +27000 +1503 +6881 +1500 +8021 +340 +5566 +8088 +2222 +9071 +8899 +6005 +9876 +1501 +5102 +32774 +32773 +9101 +5679 +163 +648 +146 +1666 +901 +83 +9207 +8001 +8083 +8084 +5004 +3476 +5214 +14238 +12345 +912 +30 +2605 +2030 +6 +541 +8007 +3005 +4 +1248 +2500 +880 +306 +4242 +1097 +9009 +2525 +1086 +1088 +8291 +52822 +6101 +900 +7200 +2809 +800 +32775 +12000 +1083 +211 +987 +705 +20005 +711 +13783 +6969 +3071 +5269 +5222 +1085 +1046 +5986 +5985 +5987 +5989 +5988 +2190 +3301 +11967 +8600 +3766 +7627 +8087 +30000 +9010 +7741 +14000 +3367 +1099 +1098 +3031 +2718 +6580 +15002 +4129 +6901 +3827 +3580 +2144 +8181 +3801 +1718 +2811 +9080 +2135 +1045 +2399 +3017 +10002 +1148 +9002 +8873 +2875 +9011 +5718 +8086 +20000 +3998 +2607 +11110 +4126 +9618 +2381 +1096 +3300 +3351 +1073 +8333 +3784 +5633 +15660 +6123 +3211 +1078 +5910 +5911 +3659 +3551 +2260 +2160 +2100 +16001 +3325 +3323 +1104 +9968 +9503 +9502 +9485 +9290 +9220 +8994 +8649 +8222 +7911 +7625 +7106 +65129 +63331 +6156 +6129 +60020 +5962 +5961 +5960 +5959 +5925 +5877 +5825 +5810 +58080 +57294 +50800 +50006 +50003 +49160 +49159 +49158 +48080 +40193 +34573 +34572 +34571 +3404 +33899 +32782 +32781 +31038 +30718 +28201 +27715 +25734 +24800 +22939 +21571 +20221 +20031 +19842 +19801 +19101 +17988 +1783 +16018 +16016 +15003 +14442 +13456 +10629 +10628 +10626 +10621 +10617 +10616 +10566 +10025 +10024 +10012 +1169 +5030 +5414 +1057 +6788 +1947 +1094 +1075 +1108 +4003 +1081 +1093 +4449 +1687 +1840 +1100 +1063 +1061 +9900 +1107 +1106 +9500 +20222 +7778 +1077 +1310 +2119 +2492 +1070 +8400 +1272 +6389 +7777 +1072 +1079 +1082 +8402 +89 +691 +1001 +32776 +1999 +212 +2020 +6003 +7002 +2998 +50002 +3372 +898 +5510 +32 +2033 +99 +749 +425 +5903 +43 +5405 +6106 +13722 +6502 +7007 +458 +9666 +8100 +3737 +5298 +1152 +8090 +2191 +3011 +1580 +9877 +5200 +3851 +3371 +3370 +3369 +7402 +5054 +3918 +3077 +7443 +3493 +3828 +1186 +2179 +1183 +19315 +19283 +3995 +5963 +1124 +8500 +1089 +10004 +2251 +1087 +5280 +3871 +3030 +62078 +5904 +9091 +4111 +1334 +3261 +2522 +5859 +1247 +9944 +9943 +9110 +8654 +8254 +8180 +8011 +7512 +7435 +7103 +61900 +61532 +5922 +5915 +5822 +56738 +55055 +51493 +50636 +50389 +49175 +49165 +49163 +3546 +32784 +27355 +27353 +27352 +24444 +19780 +18988 +16012 +15742 +10778 +4006 +2126 +4446 +3880 +1782 +1296 +9998 +9040 +32779 +1021 +32777 +2021 +32778 +616 +666 +700 +5802 +4321 +545 +1524 +1112 +49400 +84 +38292 +2040 +32780 +3006 +2111 +1084 +1600 +2048 +2638 +9111 +6699 +16080 +6547 +6007 +1533 +5560 +2106 +1443 +667 +720 +2034 +555 +801 +6025 +3221 +3826 +9200 +2608 +4279 +7025 +11111 +3527 +1151 +8200 +8300 +6689 +9878 +10009 +8800 +5730 +2394 +2393 +2725 +6566 +9081 +5678 +5906 +3800 +4550 +5080 +1201 +3168 +3814 +1862 +1114 +6510 +3905 +8383 +3914 +3971 +3809 +5033 +7676 +3517 +4900 +3869 +9418 +2909 +3878 +8042 +1091 +1090 +3920 +6567 +1138 +3945 +1175 +10003 +3390 +5907 +3889 +1131 +8292 +5087 +1119 +1117 +4848 +7800 +16000 +3324 +3322 +5221 +4445 +9917 +9575 +9099 +9003 +8290 +8099 +8093 +8045 +7921 +7920 +7496 +6839 +6792 +6779 +6692 +6565 +60443 +5952 +5950 +5862 +5850 +5815 +5811 +57797 +56737 +5544 +55056 +5440 +54328 +54045 +52848 +52673 +50500 +50300 +49176 +49167 +49161 +44501 +44176 +41511 +40911 +32785 +32783 +30951 +27356 +26214 +25735 +19350 +18101 +18040 +17877 +16113 +15004 +14441 +12265 +12174 +10215 +10180 +4567 +6100 +5061 +4004 +4005 +8022 +9898 +7999 +1271 +1199 +3003 +1122 +2323 +4224 +2022 +617 +777 +417 +714 +6346 +981 +722 +1009 +4998 +70 +1076 +5999 +10082 +765 +301 +524 +668 +2041 +6009 +1417 +1434 +259 +44443 +1984 +2068 +7004 +1007 +4343 +416 +2038 +6006 +109 +4125 +1461 +9103 +911 +726 +1010 +2046 +2035 +7201 +687 +2013 +481 +125 +6669 +6668 +903 +1455 +683 +1011 +2043 +2047 +256 +9929 +5998 +406 +31337 +44442 +783 +843 +2042 +2045 +4040 +6060 +6051 +1145 +3916 +9443 +9444 +1875 +7272 +4252 +4200 +7024 +1556 +13724 +1141 +1233 +8765 +1137 +3963 +5938 +9191 +3808 +8686 +3981 +2710 +3852 +3849 +3944 +3853 +9988 +1163 +4164 +3820 +6481 +3731 +5081 +40000 +8097 +4555 +3863 +1287 +4430 +7744 +7913 +1166 +1164 +1165 +8019 +10160 +4658 +7878 +3304 +3307 +1259 +1092 +7278 +3872 +10008 +7725 +3410 +1971 +3697 +3859 +3514 +4949 +4147 +7900 +5353 +3931 +8675 +1277 +3957 +1213 +2382 +6600 +3700 +3007 +4080 +1113 +3969 +1132 +1309 +3848 +7281 +3907 +3972 +3968 +1126 +5223 +1217 +3870 +3941 +8293 +1719 +1300 +2099 +6068 +3013 +3050 +1174 +3684 +2170 +3792 +1216 +5151 +7123 +7080 +22222 +4143 +5868 +8889 +12006 +1121 +3119 +8015 +10023 +3824 +1154 +20002 +3888 +4009 +5063 +3376 +1185 +1198 +1192 +1972 +1130 +1149 +4096 +6500 +8294 +3990 +3993 +8016 +5242 +3846 +3929 +1187 +5074 +5909 +8766 +5905 +1102 +2800 +9941 +9914 +9815 +9673 +9643 +9621 +9501 +9409 +9198 +9197 +9098 +8996 +8987 +8877 +8676 +8648 +8540 +8481 +8385 +8189 +8098 +8095 +8050 +7929 +7770 +7749 +7438 +7241 +7051 +7050 +6896 +6732 +6711 +65310 +6520 +6504 +6247 +6203 +61613 +60642 +60146 +60123 +5981 +5940 +59202 +59201 +59200 +5918 +5914 +59110 +5899 +58838 +5869 +58632 +58630 +5823 +5818 +5812 +5807 +58002 +58001 +57665 +55576 +55020 +53535 +5339 +53314 +53313 +53211 +52853 +52851 +52850 +52849 +52847 +5279 +52735 +52710 +52660 +5212 +51413 +51191 +5040 +50050 +49401 +49236 +49195 +49186 +49171 +49168 +49164 +4875 +47544 +46996 +46200 +44709 +41523 +41064 +40811 +3994 +39659 +39376 +39136 +38188 +38185 +37839 +35513 +33554 +33453 +32835 +32822 +32816 +32803 +32792 +32791 +30704 +30005 +29831 +29672 +28211 +27357 +26470 +23796 +23052 +2196 +21792 +19900 +18264 +18018 +17595 +16851 +16800 +16705 +15402 +15001 +12452 +12380 +12262 +12215 +12059 +12021 +10873 +10058 +10034 +10022 +10011 +2910 +1594 +1658 +1583 +3162 +2920 +1812 +26000 +2366 +4600 +1688 +1322 +2557 +1095 +1839 +2288 +1123 +5968 +9600 +1244 +1641 +2200 +1105 +6550 +5501 +1328 +2968 +1805 +1914 +1974 +31727 +3400 +1301 +1147 +1721 +1236 +2501 +2012 +6222 +1220 +1109 +1347 +502 +701 +2232 +2241 +4559 +710 +10005 +5680 +623 +913 +1103 +780 +930 +803 +725 +639 +540 +102 +5010 +1222 +953 +8118 +9992 +1270 +27 +123 +86 +447 +1158 +442 +18000 +419 +931 +874 +856 +250 +475 +2044 +441 +210 +6008 +7003 +5803 +1008 +556 +6103 +829 +3299 +55 +713 +1550 +709 +2628 +223 +3025 +87 +57 +10083 +5520 +980 +251 +1013 +9152 +1212 +2433 +1516 +333 +2011 +748 +1350 +1526 +7010 +1241 +127 +157 +220 +1351 +2067 +684 +77 +4333 +674 +943 +904 +840 +825 +792 +732 +1020 +1006 +657 +557 +610 +1547 +523 +996 +2025 +602 +3456 +862 +600 +2903 +257 +1522 +1353 +6662 +998 +660 +729 +730 +731 +782 +1357 +3632 +3399 +6050 +2201 +971 +969 +905 +846 +839 +823 +822 +795 +790 +778 +757 +659 +225 +1015 +1014 +1012 +655 +786 +6017 +6670 +690 +388 +44334 +754 +5011 +98 +411 +1525 +3999 +740 +12346 +802 +1337 +1127 +2112 +1414 +2600 +621 +606 +59 +928 +924 +922 +921 +918 +878 +864 +859 +806 +805 +728 +252 +1005 +1004 +641 +758 +669 +38037 +715 +1413 +2104 +1229 +3817 +6063 +6062 +6055 +6052 +6030 +6021 +6015 +6010 +3220 +6115 +3940 +2340 +8006 +4141 +3810 +1565 +3511 +33000 +2723 +9202 +4036 +4035 +2312 +3652 +3280 +4243 +4298 +4297 +4294 +4262 +4234 +4220 +4206 +22555 +9300 +7121 +1927 +4433 +5070 +2148 +1168 +9979 +7998 +4414 +1823 +3653 +1223 +8201 +4876 +3240 +2644 +4020 +2436 +3906 +4375 +4024 +5581 +5580 +9694 +6251 +7345 +7325 +7320 +7300 +3121 +5473 +5475 +3600 +3943 +4912 +2142 +1976 +1975 +5202 +5201 +4016 +5111 +9911 +10006 +3923 +3930 +1221 +2973 +3909 +5814 +3080 +4158 +3526 +1911 +5066 +2711 +2187 +3788 +3796 +3922 +2292 +16161 +4881 +3979 +3670 +4174 +3102 +3483 +2631 +1750 +3897 +7500 +5553 +5554 +9875 +4570 +3860 +3712 +8052 +2083 +8883 +2271 +4606 +1208 +3319 +3935 +3430 +1215 +3962 +3368 +3964 +1128 +5557 +4010 +9400 +1605 +3291 +7400 +5005 +1699 +1195 +5053 +3813 +1712 +3002 +3765 +3806 +43000 +2371 +3532 +3799 +3790 +3599 +3850 +4355 +4358 +4357 +4356 +5433 +3928 +4713 +4374 +3961 +9022 +3911 +3396 +7628 +3200 +1753 +3967 +2505 +5133 +3658 +8471 +1314 +2558 +6161 +4025 +3089 +9021 +30001 +8472 +5014 +9990 +1159 +1157 +1308 +5723 +3443 +4161 +1135 +9211 +9210 +4090 +7789 +6619 +9628 +12121 +4454 +3680 +3167 +3902 +3901 +3890 +3842 +16900 +4700 +4687 +8980 +1196 +4407 +3520 +3812 +5012 +10115 +1615 +2902 +4118 +2706 +2095 +2096 +3363 +5137 +3795 +8005 +10007 +3515 +8003 +3847 +3503 +5252 +27017 +2197 +4120 +1180 +5722 +1134 +1883 +1249 +3311 +27350 +3837 +2804 +4558 +4190 +2463 +1204 +4056 +1184 +19333 +9333 +3913 +3672 +4342 +4877 +3586 +8282 +1861 +1752 +9592 +1701 +6085 +2081 +4058 +2115 +8900 +4328 +2958 +2957 +7071 +3899 +2531 +2691 +5052 +1638 +3419 +2551 +5908 +4029 +3603 +1336 +2082 +1143 +3602 +1176 +4100 +3486 +6077 +4800 +2062 +1918 +12001 +12002 +9084 +7072 +1156 +2313 +3952 +4999 +5023 +2069 +28017 +27019 +27018 +3439 +6324 +1188 +1125 +3908 +7501 +8232 +1722 +2988 +10500 +1136 +1162 +10020 +22128 +1211 +3530 +12009 +9005 +3057 +3956 +4325 +1191 +3519 +5235 +1144 +4745 +1901 +1807 +2425 +3210 +32767 +5015 +5013 +3622 +4039 +10101 +5233 +5152 +3983 +3982 +9616 +4369 +3728 +3621 +2291 +5114 +7101 +1315 +2087 +5234 +1635 +3263 +4121 +4602 +2224 +3949 +9131 +3310 +3937 +2253 +3882 +3831 +2376 +2375 +3876 +3362 +3663 +3334 +47624 +1825 +4302 +5721 +1279 +2606 +1173 +22125 +17500 +12005 +6113 +1973 +3793 +3637 +8954 +3742 +9667 +41795 +41794 +4300 +8445 +12865 +3365 +4665 +3190 +3577 +3823 +2261 +2262 +2812 +1190 +22350 +3374 +4135 +2598 +2567 +1167 +8470 +10443 +8116 +3830 +8880 +2734 +3505 +3388 +3669 +1871 +8025 +1958 +3681 +3014 +8999 +4415 +3414 +4101 +6503 +9700 +3683 +1150 +18333 +4376 +3991 +3989 +3992 +2302 +3415 +1179 +3946 +2203 +4192 +4418 +2712 +25565 +4065 +5820 +3915 +2080 +3103 +2265 +8202 +2304 +8060 +4119 +4401 +1560 +3904 +4534 +1835 +1116 +8023 +8474 +3879 +4087 +4112 +6350 +9950 +3506 +3948 +3825 +2325 +1800 +1153 +6379 +3839 +4689 +47806 +5912 +3975 +3980 +4113 +2847 +2070 +3425 +6628 +3997 +3513 +3656 +2335 +1182 +1954 +3996 +4599 +2391 +3479 +5021 +5020 +1558 +1924 +4545 +2991 +6065 +1290 +1559 +1317 +5423 +1707 +5055 +9975 +9971 +9919 +9915 +9912 +9910 +9908 +9901 +9844 +9830 +9826 +9825 +9823 +9814 +9812 +9777 +9745 +9683 +9680 +9679 +9674 +9665 +9661 +9654 +9648 +9620 +9619 +9613 +9583 +9527 +9513 +9493 +9478 +9464 +9454 +9364 +9351 +9183 +9170 +9133 +9130 +9128 +9125 +9065 +9061 +9044 +9037 +9013 +9004 +8925 +8898 +8887 +8882 +8879 +8878 +8865 +8843 +8801 +8798 +8790 +8772 +8756 +8752 +8736 +8680 +8673 +8658 +8655 +8644 +8640 +8621 +8601 +8562 +8539 +8531 +8530 +8515 +8484 +8479 +8477 +8455 +8454 +8453 +8452 +8451 +8409 +8339 +8308 +8295 +8273 +8268 +8255 +8248 +8245 +8144 +8133 +8110 +8092 +8064 +8037 +8029 +8018 +8014 +7975 +7895 +7854 +7853 +7852 +7830 +7813 +7788 +7780 +7772 +7771 +7688 +7685 +7654 +7637 +7600 +7555 +7553 +7456 +7451 +7231 +7218 +7184 +7119 +7104 +7102 +7092 +7068 +7067 +7043 +7033 +6973 +6972 +6956 +6942 +6922 +6920 +6897 +6877 +6780 +6734 +6725 +6710 +6709 +6650 +6647 +6644 +6606 +65514 +65488 +6535 +65311 +65048 +64890 +64727 +64726 +64551 +64507 +64438 +64320 +6412 +64127 +64080 +63803 +63675 +6349 +63423 +6323 +63156 +6310 +63105 +6309 +62866 +6274 +6273 +62674 +6259 +62570 +62519 +6250 +62312 +62188 +62080 +62042 +62006 +61942 +61851 +61827 +61734 +61722 +61669 +61617 +61616 +61516 +61473 +61402 +6126 +6120 +61170 +61169 +61159 +60989 +6091 +6090 +60794 +60789 +60783 +60782 +60753 +60743 +60728 +60713 +6067 +60628 +60621 +60612 +60579 +60544 +60504 +60492 +60485 +60403 +60401 +60377 +60279 +60243 +60227 +60177 +60111 +60086 +60055 +60003 +60002 +60000 +59987 +59841 +59829 +59810 +59778 +5975 +5974 +5971 +59684 +5966 +5958 +59565 +5954 +5953 +59525 +59510 +59509 +59504 +5949 +59499 +5948 +5945 +5939 +5936 +5934 +59340 +5931 +5927 +5926 +5924 +5923 +59239 +5921 +5920 +59191 +5917 +59160 +59149 +59122 +59107 +59087 +58991 +58970 +58908 +5888 +5887 +5881 +5878 +5875 +5874 +58721 +5871 +58699 +58634 +58622 +58610 +5860 +5858 +58570 +58562 +5854 +5853 +5852 +5849 +58498 +5848 +58468 +5845 +58456 +58446 +58430 +5840 +5839 +5838 +58374 +5836 +5834 +5831 +58310 +58305 +5827 +5826 +58252 +5824 +5821 +5817 +58164 +58109 +58107 +5808 +58072 +5806 +5804 +57999 +57988 +57928 +57923 +57896 +57891 +57733 +57730 +57702 +57681 +57678 +57576 +57479 +57398 +57387 +5737 +57352 +57350 +5734 +57347 +57335 +5732 +57325 +57123 +5711 +57103 +57020 +56975 +56973 +56827 +56822 +56810 +56725 +56723 +56681 +5667 +56668 +5665 +56591 +56535 +56507 +56293 +56259 +5622 +5621 +5620 +5612 +5611 +56055 +56016 +55948 +55910 +55907 +55901 +55781 +55773 +55758 +55721 +55684 +55652 +55635 +55579 +55569 +55568 +55556 +5552 +55527 +55479 +55426 +55400 +55382 +55350 +55312 +55227 +55187 +55183 +55000 +54991 +54987 +54907 +54873 +54741 +54722 +54688 +54658 +54605 +5458 +5457 +54551 +54514 +5444 +5442 +5441 +54323 +54321 +54276 +54263 +54235 +54127 +54101 +54075 +53958 +53910 +53852 +53827 +53782 +5377 +53742 +5370 +53690 +53656 +53639 +53633 +53491 +5347 +53469 +53460 +53370 +53361 +53319 +53240 +53212 +53189 +53178 +53085 +52948 +5291 +52893 +52675 +52665 +5261 +5259 +52573 +52506 +52477 +52391 +52262 +52237 +52230 +52226 +52225 +5219 +52173 +52071 +52046 +52025 +52003 +52002 +52001 +52000 +51965 +51961 +51909 +51906 +51809 +51800 +51772 +51771 +51658 +51582 +51515 +51488 +51485 +51484 +5147 +51460 +51423 +51366 +51351 +51343 +51300 +5125 +51240 +51235 +51234 +51233 +5122 +5121 +51139 +51118 +51067 +51037 +51020 +51011 +50997 +5098 +5096 +5095 +50945 +5090 +50903 +5088 +50887 +50854 +50849 +50836 +50835 +50834 +50833 +50831 +50815 +50809 +50787 +50733 +50692 +50585 +50577 +50576 +50545 +50529 +50513 +50356 +50277 +50258 +50246 +50224 +50205 +50202 +50198 +50189 +5017 +5016 +50101 +50040 +50019 +50016 +49927 +49803 +49765 +49762 +49751 +49678 +49603 +49597 +49522 +49521 +49520 +49519 +49500 +49498 +49452 +49398 +49372 +49352 +4931 +49302 +49275 +49241 +49235 +49232 +49228 +49216 +49213 +49211 +49204 +49203 +49202 +49201 +49197 +49196 +49191 +49190 +49189 +49179 +49173 +49172 +49170 +49169 +49166 +49132 +49048 +4903 +49002 +48973 +48967 +48966 +48925 +48813 +48783 +48682 +48648 +48631 +4860 +4859 +48434 +48356 +4819 +48167 +48153 +48127 +48083 +48067 +48009 +47969 +47966 +4793 +47860 +47858 +47850 +4778 +47777 +4771 +4770 +47700 +4767 +47634 +4760 +47595 +47581 +47567 +47448 +47372 +47348 +47267 +47197 +4712 +47119 +47029 +47012 +46992 +46813 +46593 +4649 +4644 +46436 +46418 +46372 +46310 +46182 +46171 +46115 +4609 +46069 +46034 +45960 +45864 +45777 +45697 +45624 +45602 +45463 +45438 +45413 +4530 +45226 +45220 +4517 +4516 +45164 +45136 +45050 +45038 +44981 +44965 +4476 +4471 +44711 +44704 +4464 +44628 +44616 +44541 +44505 +44479 +44431 +44410 +44380 +44200 +44119 +44101 +44004 +4388 +43868 +4384 +43823 +43734 +43690 +43654 +43425 +43242 +43231 +43212 +43143 +43139 +43103 +43027 +43018 +43002 +42990 +42906 +42735 +42685 +42679 +42675 +42632 +42590 +42575 +42560 +42559 +42452 +42449 +42322 +42276 +42251 +42158 +42127 +42035 +42001 +41808 +41773 +41632 +41551 +41442 +41398 +41348 +41345 +41342 +41318 +41281 +41250 +41142 +41123 +40951 +40834 +40812 +40754 +40732 +40712 +40628 +40614 +40513 +40489 +40457 +40400 +40393 +40306 +40011 +40005 +40003 +40002 +40001 +39917 +39895 +39883 +39869 +39795 +39774 +39763 +39732 +39630 +39489 +39482 +39433 +39380 +39293 +39265 +39117 +39067 +38936 +38805 +38780 +38764 +38761 +38570 +38561 +38546 +38481 +38446 +38358 +38331 +38313 +38270 +38224 +38205 +38194 +38029 +37855 +37789 +37777 +37674 +37647 +37614 +37607 +37522 +37393 +37218 +37185 +37174 +37151 +37121 +36983 +36962 +36950 +36914 +36824 +36823 +36748 +36710 +36694 +36677 +36659 +36552 +36530 +36508 +36436 +36368 +36275 +36256 +36105 +36104 +36046 +35986 +35929 +35906 +35901 +35900 +35879 +35731 +35593 +35553 +35506 +35401 +35393 +35392 +35349 +35272 +35217 +35131 +35116 +35050 +35033 +34875 +34833 +34783 +34765 +34728 +34683 +34510 +34507 +34401 +34381 +34341 +34317 +34189 +34096 +34036 +34021 +33895 +33889 +33882 +33879 +33841 +33605 +33604 +33550 +33523 +33522 +33444 +33395 +33367 +33337 +33335 +33327 +33277 +33203 +33200 +33192 +33175 +33124 +33087 +33070 +33017 +33011 +32976 +32961 +32960 +32944 +32932 +32911 +32910 +32908 +32905 +32904 +32898 +32897 +32888 +32871 +32869 +32868 +32858 +32842 +32837 +32820 +32815 +32814 +32807 +32799 +32798 +32797 +32790 +32789 +32788 +32765 +32764 +32261 +32260 +32219 +32200 +32102 +32088 +32031 +32022 +32006 +31728 +31657 +31522 +31438 +31386 +31339 +31072 +31058 +31033 +30896 +30705 +30659 +30644 +30599 +30519 +30299 +30195 +30087 +29810 +29507 +29243 +29152 +29045 +28967 +28924 +28851 +28850 +28717 +28567 +28374 +28142 +28114 +27770 +27537 +27521 +27372 +27351 +27316 +27204 +27087 +27075 +27074 +27055 +27016 +27015 +26972 +26669 +26417 +26340 +26007 +26001 +25847 +25717 +25703 +25486 +25473 +25445 +25327 +25288 +25262 +25260 +25174 +24999 +24616 +24552 +24416 +24392 +24218 +23953 +23887 +23723 +23451 +23430 +23382 +23342 +23296 +23270 +23228 +23219 +23040 +23017 +22969 +22959 +22882 +22769 +22727 +22719 +22711 +22563 +22341 +22290 +22223 +22200 +22177 +22100 +22063 +22022 +21915 +21891 +21728 +21634 +21631 +21473 +21078 +21011 +20990 +20940 +20934 +20883 +20734 +20473 +20280 +20228 +20227 +20226 +20225 +20224 +20223 +20180 +20179 +20147 +20127 +20125 +20118 +20111 +20106 +20102 +20089 +20085 +20080 +20076 +20052 +20039 +20032 +20021 +20017 +20011 +19996 +19995 +19852 +19715 +19634 +19612 +19501 +19464 +19403 +19353 +19201 +19200 +19130 +19010 +18962 +18910 +18887 +18874 +18669 +18569 +18517 +18505 +18439 +18380 +18337 +18336 +18231 +18148 +18080 +18015 +18012 +17997 +17985 +17969 +17867 +17860 +17802 +17801 +17715 +17702 +17701 +17700 +17413 +17409 +17255 +17251 +17129 +17089 +17070 +17017 +17016 +16901 +16845 +16797 +16725 +16724 +16723 +16464 +16372 +16349 +16297 +16286 +16283 +16273 +16270 +16048 +15915 +15758 +15730 +15722 +15677 +15670 +15646 +15645 +15631 +15550 +15448 +15344 +15317 +15275 +15191 +15190 +15145 +15050 +15005 +14916 +14891 +14827 +14733 +14693 +14545 +14534 +14444 +14443 +14418 +14254 +14237 +14218 +14147 +13899 +13846 +13784 +13766 +13730 +13723 +13695 +13580 +13502 +13359 +13340 +13318 +13306 +13265 +13264 +13261 +13250 +13229 +13194 +13193 +13192 +13188 +13167 +13149 +13142 +13140 +13132 +13130 +13093 +13017 +12962 +12955 +12892 +12891 +12766 +12702 +12699 +12414 +12340 +12296 +12275 +12271 +12251 +12243 +12240 +12225 +12192 +12171 +12156 +12146 +12137 +12132 +12097 +12096 +12090 +12080 +12077 +12034 +12031 +12019 +11940 +11863 +11862 +11813 +11735 +11697 +11552 +11401 +11296 +11288 +11250 +11224 +11200 +11180 +11100 +11089 +11033 +11032 +11031 +11026 +11019 +11007 +11003 +10900 +10878 +10852 +10842 +10754 +10699 +10602 +10601 +10567 +10565 +10556 +10555 +10554 +10553 +10552 +10551 +10550 +10535 +10529 +10509 +10494 +10414 +10387 +10357 +10347 +10338 +10280 +10255 +10246 +10245 +10238 +10093 +10064 +10045 +10042 +10035 +10019 +10018 +1327 +2330 +2580 +2700 +1584 +9020 +3281 +2439 +1250 +14001 +1607 +1736 +1330 +2270 +2728 +2888 +3803 +5250 +1645 +1303 +3636 +1251 +1243 +1291 +1297 +1200 +1811 +4442 +1118 +8401 +2101 +2889 +1694 +1730 +1912 +29015 +28015 +1745 +2250 +1306 +2997 +2449 +1262 +4007 +1101 +1268 +1735 +1858 +1264 +1711 +3118 +4601 +1321 +1598 +1305 +1632 +9995 +1307 +1981 +2532 +1808 +2435 +1194 +1622 +1239 +1799 +2882 +1683 +3063 +3062 +1340 +4447 +1806 +6888 +2438 +1261 +5969 +9343 +2583 +2031 +3798 +2269 +20001 +2622 +11001 +1207 +2850 +21201 +2908 +3936 +3023 +2280 +2623 +7099 +2372 +1318 +1339 +1276 +11000 +48619 +3497 +1209 +1331 +1240 +3856 +2987 +2326 +25001 +25000 +1792 +3919 +1299 +2984 +1715 +1703 +1677 +2086 +1708 +1228 +3787 +5502 +1620 +1316 +1569 +1210 +1691 +1282 +2124 +1791 +2150 +9909 +4022 +3868 +1324 +2584 +2300 +9287 +2806 +1566 +1713 +1592 +3749 +1302 +1709 +3485 +2418 +2472 +24554 +3146 +2134 +2898 +9161 +9160 +2930 +1319 +5672 +3811 +2456 +2901 +6579 +2550 +8403 +31416 +22273 +7005 +66 +32786 +32787 +706 +914 +635 +6105 +400 +47 +830 +4008 +5977 +1989 +1444 +3985 +678 +27001 +591 +642 +446 +1441 +54320 +11 +769 +983 +979 +973 +967 +965 +961 +942 +935 +926 +925 +863 +858 +844 +834 +817 +815 +811 +809 +789 +779 +743 +1019 +1507 +1492 +509 +762 +5632 +578 +1495 +5308 +52 +219 +525 +1420 +665 +620 +3064 +3045 +653 +158 +716 +861 +9991 +3049 +1366 +1364 +833 +91 +1680 +3398 +750 +615 +603 +6110 +101 +989 +27010 +510 +810 +1139 +4199 +76 +847 +649 +707 +68 +449 +664 +75 +104 +629 +1652 +682 +577 +985 +984 +974 +958 +952 +949 +946 +923 +916 +899 +897 +894 +889 +835 +824 +814 +807 +804 +798 +733 +727 +237 +12 +10 +501 +122 +440 +771 +1663 +828 +860 +695 +634 +538 +1359 +1358 +1517 +1370 +3900 +492 +268 +27374 +605 +8076 +1651 +1178 +6401 +761 +5145 +50 +2018 +1349 +2014 +7597 +2120 +1445 +1402 +1465 +9104 +627 +4660 +7273 +950 +1384 +1388 +760 +92 +831 +5978 +4557 +45 +112 +456 +1214 +3086 +702 +6665 +1404 +651 +5300 +6347 +5400 +1389 +647 +448 +1356 +5232 +1484 +450 +1991 +1988 +1523 +1400 +1399 +221 +1385 +5191 +1346 +2024 +2430 +988 +962 +948 +945 +941 +938 +936 +929 +927 +919 +906 +883 +881 +875 +872 +870 +866 +855 +851 +850 +841 +836 +826 +820 +819 +816 +813 +791 +745 +736 +735 +724 +719 +343 +334 +300 +28 +249 +230 +16 +1018 +1016 +658 +1474 +696 +630 +663 +2307 +1552 +609 +741 +353 +638 +1551 +661 +491 +640 +507 +673 +632 +1354 +9105 +6143 +676 +214 +14141 +182 +69 +27665 +1475 +97 +633 +560 +799 +7009 +2015 +628 +751 +4480 +1403 +8123 +1527 +723 +1466 +1486 +1650 +991 +832 +137 +1348 +685 +1762 +6701 +994 +4500 +194 +180 +1539 +1379 +51 +886 +2064 +1405 +1435 +11371 +1401 +1369 +402 +103 +1372 +704 +854 +8892 +47557 +624 +1387 +3397 +1996 +1995 +1997 +18182 +18184 +3264 +3292 +13720 +9107 +9106 +201 +1381 +35 +6588 +5530 +3141 +670 +970 +968 +964 +963 +960 +959 +951 +947 +944 +939 +933 +909 +895 +891 +879 +869 +868 +867 +837 +821 +812 +797 +796 +794 +788 +756 +734 +721 +718 +708 +703 +60 +40 +253 +231 +14 +1017 +1003 +656 +975 +2026 +1497 +553 +511 +611 +689 +1668 +1664 +15 +561 +997 +505 +1496 +637 +213 +1412 +1515 +692 +694 +681 +680 +644 +675 +1467 +454 +622 +1476 +1373 +770 +262 +654 +1535 +58 +177 +26208 +677 +1519 +1398 +3457 +401 +412 +493 +13713 +94 +1498 +871 +1390 +6145 +133 +362 +118 +193 +115 +1549 +7008 +608 +1426 +1436 +915 +38 +74 +73 +71 +601 +136 +4144 +129 +16444 +1446 +4132 +308 +1528 +1365 +1393 +1394 +1493 +138 +5997 +397 +29 +31 +44 +2627 +6147 +1510 +568 +350 +2053 +6146 +6544 +1763 +3531 +399 +1537 +1992 +1355 +1454 +261 +887 +200 +1376 +1424 +6111 +1410 +1409 +686 +5301 +5302 +1513 +747 +9051 +1499 +7006 +1439 +1438 +8770 +853 +196 +93 +410 +462 +619 +1529 +1990 +1994 +1986 +1386 +18183 +18181 +6700 +1442 +95 +6400 +1432 +1548 +486 +1422 +114 +1397 +6142 +1827 +626 +422 +688 +206 +202 +204 +1483 +7634 +774 +699 +2023 +776 +672 +1545 +2431 +697 +982 +978 +972 +966 +957 +956 +934 +920 +908 +907 +892 +890 +885 +884 +882 +877 +876 +865 +857 +852 +849 +842 +838 +827 +818 +793 +785 +784 +755 +746 +738 +737 +717 +34 +336 +325 +303 +276 +273 +236 +235 +233 +181 +604 +1362 +712 +1437 +2027 +1368 +1531 +645 +65301 +260 +536 +764 +698 +607 +1667 +1662 +1661 +404 +224 +418 +176 +848 +315 +466 +403 +1456 +1479 +355 +763 +1472 +453 +759 +437 +2432 +120 +415 +1544 +1511 +1538 +346 +173 +54 +56 +265 +1462 +13701 +1518 +1457 +117 +1470 +13715 +13714 +267 +1419 +1418 +1407 +380 +518 +65 +391 +392 +413 +1391 +614 +1408 +162 +108 +4987 +1502 +598 +582 +487 +530 +1509 +72 +4672 +189 +209 +270 +7464 +408 +191 +1459 +5714 +5717 +5713 +564 +767 +583 +1395 +192 +1448 +428 +4133 +1416 +773 +1458 +526 +1363 +742 +1464 +1427 +1482 +569 +571 +6141 +351 +3984 +5490 +2 +13718 +373 +17300 +910 +148 +7326 +271 +423 +1451 +480 +1430 +1429 +781 +383 +2564 +613 +612 +652 +5303 +1383 +128 +19150 +1453 +190 +1505 +1371 +533 +27009 +27007 +27005 +27003 +27002 +744 +1423 +1374 +141 +1440 +1396 +352 +96 +48 +552 +570 +217 +528 +452 +451 +2766 +2108 +132 +1993 +1987 +130 +18187 +216 +3421 +142 +13721 +67 +15151 +364 +1411 +205 +6548 +124 +116 +5193 +258 +485 +599 +149 +1469 +775 +2019 +516 +986 +977 +976 +955 +954 +937 +932 +8 +896 +893 +845 +768 +766 +739 +337 +329 +326 +305 +295 +294 +293 +289 +288 +277 +238 +234 +229 +228 +226 +522 +2028 +150 +572 +596 +420 +460 +1543 +358 +361 +470 +360 +457 +643 +322 +168 +753 +369 +185 +43188 +1541 +1540 +752 +496 +662 +1449 +1480 +1473 +184 +1672 +1671 +1670 +435 +434 +1532 +1360 +174 +472 +1361 +17007 +414 +535 +432 +479 +473 +151 +1542 +438 +1488 +1508 +618 +316 +1367 +439 +284 +542 +370 +2016 +248 +1491 +44123 +41230 +7173 +5670 +18136 +3925 +7088 +1425 +17755 +17756 +4072 +5841 +2102 +4123 +2989 +10051 +10050 +31029 +3726 +5243 +9978 +9925 +6061 +6058 +6057 +6056 +6054 +6053 +6049 +6048 +6047 +6046 +6045 +6044 +6043 +6042 +6041 +6040 +6039 +6038 +6037 +6036 +6035 +6034 +6033 +6032 +6031 +6029 +6028 +6027 +6026 +6024 +6023 +6022 +6020 +6019 +6018 +6016 +6014 +6013 +6012 +6011 +36462 +5793 +3423 +3424 +4095 +3646 +3510 +3722 +2459 +3651 +14500 +3865 +15345 +3763 +38422 +3877 +9092 +5344 +3974 +2341 +6116 +2157 +165 +6936 +8041 +4888 +4889 +3074 +2165 +4389 +5770 +5769 +16619 +11876 +11877 +3741 +3633 +3840 +3717 +3716 +3590 +2805 +4537 +9762 +5007 +5006 +5358 +4879 +6114 +4185 +2784 +3724 +2596 +2595 +4417 +4845 +22321 +22289 +3219 +1338 +36411 +3861 +5166 +3674 +1785 +534 +6602 +47001 +5363 +8912 +2231 +5747 +5748 +11208 +7236 +4049 +4050 +22347 +63 +3233 +3359 +8908 +4177 +48050 +3111 +3427 +5321 +5320 +3702 +2907 +8991 +8990 +2054 +4847 +9802 +9800 +4368 +5990 +3563 +5744 +5743 +12321 +12322 +9206 +9204 +9205 +9201 +9203 +2949 +2948 +6626 +37472 +8199 +4145 +3482 +2216 +13708 +3786 +3375 +7566 +2539 +2387 +3317 +2410 +2255 +3883 +4299 +4296 +4295 +4293 +4292 +4291 +4290 +4289 +4288 +4287 +4286 +4285 +4284 +4283 +4282 +4281 +4280 +4278 +4277 +4276 +4275 +4274 +4273 +4272 +4271 +4270 +4269 +4268 +4267 +4266 +4265 +4264 +4263 +4261 +4260 +4259 +4258 +4257 +4256 +4255 +4254 +4253 +4251 +4250 +4249 +4248 +4247 +4246 +4245 +4244 +4241 +4240 +4239 +4238 +4237 +4236 +4235 +4233 +4232 +4231 +4230 +4229 +4228 +4227 +4226 +4225 +4223 +4222 +4221 +4219 +4218 +4217 +4216 +4215 +4214 +4213 +4212 +4211 +4210 +4209 +4208 +4207 +4205 +4204 +4203 +4202 +4201 +2530 +5164 +28200 +3845 +3541 +4052 +21590 +1796 +25793 +8699 +8182 +4991 +2474 +5780 +3676 +24249 +1631 +6672 +6673 +3601 +5046 +3509 +1852 +2386 +8473 +7802 +4789 +3555 +12013 +12012 +3752 +3245 +3231 +16666 +6678 +17184 +9086 +9598 +3073 +2074 +1956 +2610 +3738 +2994 +2993 +2802 +1885 +14149 +13786 +10100 +9284 +14150 +10107 +4032 +2821 +3207 +14154 +24323 +2771 +5646 +2426 +18668 +2554 +4188 +3654 +8034 +5675 +15118 +4031 +2529 +2248 +1142 +19194 +433 +3534 +3664 +2537 +519 +2655 +4184 +1506 +3098 +7887 +37654 +1979 +9629 +2357 +1889 +3314 +3313 +4867 +2696 +3217 +6306 +1189 +5281 +8953 +1910 +13894 +372 +3720 +1382 +2542 +3584 +4034 +145 +27999 +3791 +21800 +2670 +3492 +24678 +34249 +39681 +1846 +5197 +5462 +5463 +2862 +2977 +2978 +3468 +2675 +3474 +4422 +12753 +13709 +2573 +3012 +4307 +4725 +3346 +3686 +4070 +9555 +4711 +4323 +4322 +10200 +7727 +3608 +3959 +2405 +3858 +3857 +24322 +6118 +4176 +6442 +8937 +17224 +17225 +7234 +33434 +1906 +22351 +2158 +5153 +3885 +24465 +3040 +20167 +8066 +474 +2739 +3308 +590 +3309 +7902 +7901 +7903 +20046 +5582 +5583 +7872 +13716 +13717 +13705 +6252 +2915 +1965 +3459 +3160 +3754 +3243 +10261 +7932 +7933 +5450 +11971 +379 +7548 +1832 +28080 +3805 +16789 +8320 +8321 +4423 +2296 +7359 +7358 +7357 +7356 +7355 +7354 +7353 +7352 +7351 +7350 +7349 +7348 +7347 +7346 +7344 +7343 +7342 +7341 +7340 +7339 +7338 +7337 +7336 +7335 +7334 +7333 +7332 +7331 +7330 +7329 +7328 +7327 +7324 +7323 +7322 +7321 +7319 +7318 +7317 +7316 +7315 +7314 +7313 +7312 +7311 +7310 +7309 +7308 +7307 +7306 +7305 +7304 +7303 +7302 +7301 +8140 +5196 +5195 +6130 +5474 +5471 +5472 +5470 +4146 +3713 +5048 +31457 +7631 +3544 +41121 +11600 +3696 +3549 +1380 +22951 +22800 +3521 +2060 +6083 +9668 +3552 +1814 +1977 +2576 +2729 +24680 +13710 +13712 +25900 +2403 +2402 +2470 +5203 +3579 +2306 +1450 +7015 +7012 +7011 +22763 +2156 +2493 +4019 +4018 +4017 +4015 +2392 +3175 +32249 +1627 +10104 +2609 +5406 +3251 +4094 +3241 +6514 +6418 +3734 +2679 +4953 +5008 +2880 +8243 +8280 +26133 +8555 +5629 +3547 +5639 +5638 +5637 +5115 +3723 +4950 +3895 +3894 +3491 +3318 +6419 +3185 +243 +3212 +9536 +1925 +11171 +8404 +8405 +8989 +6787 +6483 +3867 +3866 +1860 +1870 +5306 +3816 +7588 +6786 +2084 +11165 +11161 +11163 +11162 +11164 +3708 +4850 +7677 +16959 +247 +3478 +5349 +3854 +5397 +7411 +9612 +11173 +9293 +5027 +5026 +5705 +8778 +527 +1312 +8808 +6144 +4157 +4156 +3249 +7471 +3615 +5777 +2154 +45966 +17235 +3018 +38800 +2737 +156 +3807 +2876 +1759 +7981 +3606 +3647 +3438 +4683 +9306 +9312 +7016 +33334 +3413 +3834 +3835 +2440 +6121 +8668 +2568 +17185 +7982 +2290 +2569 +2863 +1964 +4738 +2132 +17777 +16162 +6551 +3230 +4538 +3884 +9282 +9281 +4882 +5146 +580 +1967 +2659 +2409 +5416 +2657 +3380 +5417 +2658 +5161 +5162 +10162 +10161 +33656 +7560 +2599 +2704 +2703 +4170 +7734 +9522 +3158 +4426 +4786 +2721 +1608 +3516 +4988 +4408 +1847 +36423 +2826 +2827 +3556 +8111 +6456 +6455 +3874 +3611 +2629 +2630 +166 +5059 +3110 +1733 +40404 +2257 +2278 +4750 +4303 +3688 +4751 +5794 +4752 +7626 +16950 +3273 +3896 +3635 +1959 +4753 +2857 +4163 +1659 +2905 +2904 +2733 +4936 +5032 +3048 +29000 +28240 +2320 +4742 +22335 +22333 +5043 +4105 +1257 +3841 +43210 +4366 +5163 +11106 +5434 +6444 +6445 +5634 +5636 +5635 +6343 +4546 +3242 +5568 +4057 +24666 +21221 +6488 +6484 +6486 +6485 +6487 +6443 +6480 +6489 +7690 +2603 +4787 +2367 +9212 +9213 +5445 +45824 +8351 +13711 +4076 +5099 +2316 +3588 +5093 +9450 +8056 +8055 +8054 +8059 +8058 +8057 +8053 +3090 +3255 +2254 +2479 +2477 +2478 +4194 +3496 +3495 +2089 +38865 +9026 +9025 +9024 +9023 +3480 +1905 +3550 +7801 +2189 +5361 +32635 +3782 +3432 +3978 +6629 +3143 +7784 +2342 +2309 +2705 +2310 +2384 +6315 +5343 +9899 +5168 +5167 +3927 +266 +2577 +5307 +3838 +19007 +7708 +37475 +7701 +5435 +3499 +2719 +3352 +25576 +3942 +1644 +3755 +5574 +5573 +7542 +9310 +1129 +4079 +3038 +8768 +4033 +9401 +9402 +20012 +20013 +30832 +1606 +5410 +5422 +5409 +9801 +7743 +14034 +14033 +4952 +21801 +3452 +2760 +3153 +23272 +2578 +5156 +8554 +7401 +3771 +3138 +3137 +3500 +6900 +363 +3455 +1698 +13217 +2752 +3864 +10201 +6568 +2377 +3677 +520 +2258 +4124 +8051 +2223 +3194 +4041 +48653 +8270 +5693 +25471 +2416 +5994 +9208 +7810 +7870 +2249 +7473 +4664 +4590 +2777 +2776 +2057 +6148 +3296 +4410 +4684 +8230 +5842 +1431 +12109 +4756 +4336 +324 +323 +3019 +39 +2225 +4733 +30100 +2999 +3422 +107 +1232 +3418 +3537 +5 +8184 +3789 +5231 +4731 +4373 +45045 +12302 +2373 +6084 +16665 +16385 +18635 +18634 +10253 +7227 +3572 +3032 +5786 +2346 +2348 +2347 +2349 +45002 +3553 +43191 +5313 +3707 +3706 +3736 +32811 +1942 +44553 +35001 +35002 +35005 +35006 +35003 +35004 +532 +2214 +5569 +3142 +2332 +3768 +2774 +2773 +6099 +2167 +2714 +2713 +3533 +4037 +2457 +1953 +9345 +21553 +2408 +2736 +2188 +18104 +1813 +469 +1596 +3178 +5430 +5676 +2177 +4841 +5028 +7980 +3166 +3554 +3566 +3843 +5677 +7040 +2589 +8153 +10055 +5464 +2497 +4354 +9222 +5083 +5082 +45825 +2612 +6980 +5689 +6209 +2523 +2490 +2468 +3543 +5543 +7794 +4193 +4951 +3951 +4093 +7747 +7997 +8117 +6140 +2873 +4329 +320 +319 +597 +3453 +4457 +2303 +5360 +4487 +409 +344 +1460 +5716 +5715 +9640 +5798 +7663 +7798 +7797 +4352 +15999 +34962 +34963 +34964 +4749 +8032 +4182 +1283 +1778 +3248 +2722 +2039 +3650 +3133 +2618 +4168 +10631 +1392 +3910 +6716 +47809 +38638 +4690 +9280 +6163 +2315 +3607 +5630 +4455 +4456 +1587 +28001 +5134 +13224 +13223 +5507 +2443 +4150 +8432 +7172 +3710 +9889 +6464 +7787 +6771 +6770 +3055 +2487 +16310 +16311 +3540 +34379 +34378 +2972 +7633 +6355 +188 +2790 +32400 +4351 +3934 +3933 +4659 +1819 +5586 +5863 +17010 +9318 +318 +5318 +2634 +4416 +5078 +3189 +6924 +3010 +15740 +1603 +2787 +4390 +468 +4869 +4868 +3177 +3347 +6124 +2350 +3208 +2520 +2441 +3109 +3557 +281 +1916 +4313 +5312 +4066 +345 +9630 +9631 +6817 +3582 +9279 +9278 +8027 +3587 +4747 +2178 +5112 +3135 +5443 +7880 +1980 +6086 +3254 +4012 +9597 +3253 +2274 +2299 +8444 +6655 +44322 +44321 +5351 +5350 +5172 +4172 +1332 +2256 +8129 +8128 +4097 +8161 +2665 +2664 +6162 +4189 +1333 +3735 +586 +6581 +6582 +4681 +4312 +4989 +7216 +3348 +3095 +6657 +30002 +7237 +3435 +2246 +1675 +31400 +4311 +9559 +6671 +6679 +3034 +40853 +11103 +3274 +3355 +3078 +3075 +3076 +8070 +2484 +2483 +3891 +1571 +1830 +1630 +8997 +8102 +2482 +2481 +5155 +5575 +3718 +22005 +22004 +22003 +22002 +2524 +1829 +2237 +3977 +3976 +3303 +19191 +3433 +5724 +2400 +7629 +6640 +2389 +30999 +2447 +3673 +7430 +7429 +7426 +7431 +7428 +7427 +9390 +4317 +35357 +7728 +8004 +5045 +8688 +1258 +5757 +5729 +5767 +5766 +5755 +5768 +4743 +9008 +9007 +3187 +20014 +4089 +3434 +4840 +4843 +3100 +314 +3154 +9994 +9993 +8767 +4304 +2428 +2199 +2198 +2185 +4428 +4429 +4162 +4395 +2056 +5402 +3340 +3339 +3341 +3338 +7275 +7274 +7277 +7276 +4359 +2077 +8769 +9966 +4732 +3320 +11175 +11174 +11172 +13706 +3523 +429 +2697 +18186 +3442 +3441 +29167 +36602 +7030 +1894 +28000 +126 +4420 +2184 +3780 +49001 +11235 +4128 +8711 +10810 +45001 +5415 +4453 +359 +3266 +36424 +2868 +7724 +396 +2645 +23402 +23400 +23401 +3016 +21010 +5215 +4663 +4803 +2338 +15126 +8433 +5209 +3406 +3405 +5627 +4088 +2210 +2244 +2817 +10111 +10110 +1242 +5299 +2252 +3649 +6421 +6420 +1617 +48001 +48002 +48003 +48005 +48004 +48000 +61 +8061 +4134 +38412 +20048 +7393 +4021 +178 +8457 +550 +2058 +2075 +2076 +3165 +6133 +2614 +2585 +4702 +4701 +2586 +3203 +3204 +4460 +16361 +16367 +16360 +16368 +4159 +170 +2293 +4703 +8981 +3409 +7549 +171 +20049 +1155 +537 +3196 +3195 +2411 +2788 +4127 +6777 +6778 +1879 +5421 +3440 +2128 +21846 +21849 +21847 +21848 +395 +154 +155 +4425 +2328 +3129 +3641 +3640 +1970 +2486 +2485 +6842 +6841 +3149 +3148 +3150 +3151 +1406 +218 +10116 +10114 +2219 +2735 +10117 +10113 +2220 +3725 +5229 +4350 +6513 +4335 +4334 +5681 +1676 +2971 +4409 +3131 +4441 +1612 +1616 +1613 +1614 +13785 +11104 +11105 +3829 +11095 +3507 +3213 +7474 +3886 +4043 +2730 +377 +378 +3024 +2738 +2528 +4844 +4842 +5979 +1888 +2093 +2094 +20034 +2163 +3159 +6317 +4361 +2895 +3753 +2343 +3015 +1790 +3950 +6363 +9286 +9285 +7282 +6446 +2273 +33060 +2388 +9119 +3733 +32801 +4421 +7420 +9903 +6622 +5354 +7742 +2305 +2791 +8115 +3122 +2855 +8276 +2871 +4554 +2171 +2172 +2173 +2174 +7680 +3343 +7392 +3958 +3358 +46 +6634 +8503 +3924 +2488 +10544 +10543 +10541 +10540 +10542 +4691 +8666 +1576 +4986 +6997 +3732 +4688 +7871 +9632 +7869 +2593 +3764 +5237 +4668 +4173 +4667 +8077 +4310 +7606 +5136 +4069 +21554 +7391 +9445 +2180 +3180 +2621 +4551 +3008 +7013 +7014 +5362 +6601 +1512 +5356 +6074 +5726 +5364 +5725 +6076 +6075 +2175 +3132 +5359 +2176 +5022 +4679 +4680 +6509 +2266 +6382 +2230 +6390 +6370 +6360 +393 +2311 +8787 +18 +8786 +47000 +19788 +1960 +9596 +4603 +4151 +4552 +11211 +3569 +4883 +3571 +2944 +2945 +2272 +7720 +5157 +3445 +2427 +2727 +2363 +46999 +2789 +13930 +3232 +2688 +3235 +5598 +3115 +3117 +3116 +3331 +3332 +3302 +3330 +3558 +8809 +3570 +4153 +2591 +4179 +4171 +3276 +5540 +4360 +8448 +4458 +7421 +49000 +7073 +3836 +5282 +8384 +36700 +4686 +269 +9255 +6201 +2544 +2516 +5092 +2243 +4902 +313 +3691 +2453 +4345 +44900 +36444 +36443 +4894 +3747 +3746 +5044 +6471 +3079 +4913 +4741 +10805 +3487 +3157 +3068 +8162 +4083 +4082 +4081 +7026 +1983 +2289 +1629 +1628 +1634 +8101 +6482 +5254 +5058 +4044 +3591 +3592 +1903 +5062 +6087 +2090 +2465 +2466 +6200 +8208 +8207 +8204 +31620 +8205 +8206 +3278 +2145 +2143 +2147 +2146 +3767 +46336 +10933 +4341 +1969 +10809 +12300 +8191 +517 +4670 +7365 +3028 +3027 +3029 +1203 +1886 +11430 +374 +2212 +3407 +2816 +2779 +2815 +2780 +3373 +3739 +3815 +4347 +11796 +3970 +4547 +1764 +2395 +4372 +4432 +9747 +4371 +3360 +3361 +4331 +40023 +27504 +2294 +5253 +7697 +35354 +186 +30260 +4566 +584 +5696 +6623 +6620 +6621 +2502 +3112 +36865 +2918 +4661 +31016 +26262 +26263 +3642 +48048 +5309 +3155 +4166 +27442 +6583 +3215 +3214 +8901 +19020 +4160 +3094 +3093 +3777 +1937 +1938 +1939 +1940 +2097 +1936 +1810 +6244 +6243 +6242 +6241 +4107 +19541 +3529 +3528 +5230 +4327 +5883 +2205 +7095 +3794 +3473 +3472 +7181 +5034 +3627 +8091 +1578 +5673 +5049 +4880 +3258 +2828 +3719 +7478 +7280 +1636 +1637 +3775 +24321 +499 +3205 +1950 +1949 +3226 +8148 +5047 +4075 +17223 +21000 +3504 +3206 +2632 +529 +4073 +32034 +18769 +2527 +4593 +4792 +4791 +7031 +33435 +4740 +4739 +4068 +20202 +4737 +9214 +2215 +3743 +2088 +7410 +5728 +45054 +3614 +8020 +11751 +2202 +6697 +4744 +1884 +3699 +6714 +1611 +7202 +4569 +3508 +24386 +16995 +16994 +1674 +1673 +7128 +4746 +17234 +9215 +4486 +484 +5057 +5056 +7624 +2980 +4109 +49150 +215 +23005 +23004 +23003 +23002 +23001 +23000 +2716 +3560 +5597 +134 +38001 +38000 +4067 +1428 +2480 +5029 +8067 +5069 +3156 +3139 +244 +7675 +7673 +7672 +7674 +2637 +4139 +3783 +3657 +11320 +8615 +585 +48128 +2239 +3596 +2055 +3186 +19000 +5165 +3420 +17220 +17221 +19998 +2404 +2079 +4152 +4604 +25604 +5742 +5741 +4553 +2799 +4801 +4802 +2063 +14143 +14142 +4061 +4062 +4063 +4064 +31948 +31949 +2276 +2275 +1881 +2078 +3660 +3661 +1920 +1919 +9085 +424 +1933 +1934 +9089 +9088 +3667 +3666 +12003 +12004 +3539 +3538 +3267 +25100 +385 +3494 +4594 +4595 +4596 +3898 +9614 +4169 +5674 +2374 +5105 +8313 +44323 +5628 +2570 +2113 +4591 +4592 +5228 +5224 +5227 +2207 +4484 +3037 +2209 +2448 +3101 +382 +381 +3209 +7510 +2206 +2690 +2208 +7738 +5317 +3329 +5316 +3449 +2029 +1985 +10125 +2597 +3634 +8231 +3250 +43438 +4884 +4117 +2467 +4148 +18516 +7397 +22370 +8807 +3921 +4306 +10860 +6440 +3740 +1161 +2641 +7630 +3804 +4197 +11108 +9954 +6791 +3623 +3769 +3036 +5315 +5305 +3542 +5304 +11720 +2517 +3179 +2979 +2356 +3745 +18262 +2186 +35356 +3436 +2152 +2123 +1452 +4729 +3761 +3136 +28010 +9340 +9339 +8710 +30400 +6267 +6269 +6268 +3757 +4755 +4754 +4026 +5117 +9277 +2947 +3386 +2217 +37483 +16002 +5687 +2072 +1909 +9122 +9123 +4131 +3912 +3229 +1880 +5688 +4332 +10800 +4985 +3108 +3475 +6080 +4790 +23053 +6081 +8190 +7017 +7283 +4730 +2159 +3429 +2660 +14145 +3484 +3762 +3222 +8322 +1421 +1859 +31765 +2914 +3051 +38201 +8881 +4340 +8074 +2678 +2677 +4110 +2731 +286 +3402 +3272 +1514 +3382 +1904 +1902 +3648 +2975 +574 +8502 +3488 +9217 +4130 +7726 +5556 +7244 +4319 +41111 +4411 +4084 +2242 +4396 +4901 +7545 +7544 +27008 +27006 +27004 +5579 +2884 +3035 +1193 +5618 +7018 +2673 +4086 +8043 +8044 +3192 +3729 +1855 +1856 +1784 +24922 +1887 +7164 +4349 +7394 +16021 +16020 +6715 +4915 +4122 +3216 +14250 +3152 +1776 +36524 +4320 +4727 +3225 +2819 +4038 +6417 +347 +3047 +2495 +10081 +38202 +19790 +2515 +2514 +4353 +38472 +10102 +4085 +3953 +4788 +3088 +3134 +3639 +4309 +2755 +1928 +5075 +26486 +5401 +3759 +43440 +1926 +1982 +1798 +9981 +4536 +4535 +1504 +592 +1267 +6935 +2036 +6316 +2221 +44818 +34980 +2380 +2379 +6107 +1772 +8416 +8417 +8266 +4023 +3629 +9617 +3679 +3727 +4942 +4941 +4940 +43439 +3628 +3620 +5116 +3259 +4666 +4669 +3819 +37601 +5084 +5085 +3383 +5599 +5600 +5601 +3665 +1818 +3044 +1295 +7962 +7117 +121 +17754 +6636 +6635 +20480 +23333 +3585 +6322 +6321 +4091 +4092 +140 +6656 +3693 +11623 +11723 +13218 +3682 +3218 +9083 +3197 +3198 +394 +2526 +7700 +7707 +2916 +2917 +4370 +6515 +12010 +5398 +3564 +4346 +1378 +1893 +3525 +3638 +2228 +6632 +3392 +3671 +6159 +3462 +3461 +3464 +3465 +3460 +3463 +3123 +34567 +8149 +6703 +6702 +2263 +3477 +3524 +6160 +17729 +3711 +45678 +2168 +3328 +38462 +3932 +3295 +2164 +3395 +2874 +3246 +3247 +4191 +4028 +3489 +4556 +5684 +13929 +31685 +9987 +4060 +13819 +13820 +13821 +13818 +13822 +2420 +7547 +3685 +2193 +4427 +1930 +8913 +7021 +7020 +5719 +5565 +5245 +6326 +6320 +6325 +3522 +44544 +13400 +6088 +3568 +8567 +3567 +5567 +7165 +4142 +3161 +5352 +195 +1172 +5993 +3199 +3574 +4059 +1177 +3624 +19999 +4646 +21212 +246 +5107 +14002 +7171 +3448 +3336 +3335 +3337 +198 +197 +3447 +5031 +4605 +2464 +2227 +3223 +1335 +2226 +33333 +2762 +2761 +3227 +3228 +33331 +2861 +2860 +2098 +4301 +3252 +547 +546 +6785 +8750 +4330 +3776 +24850 +8805 +2763 +4167 +2092 +3444 +8415 +3714 +1278 +5700 +3668 +7569 +365 +8894 +8893 +8891 +8890 +11202 +3988 +1160 +3938 +6117 +6624 +6625 +2073 +461 +3612 +3578 +11109 +2229 +1775 +2764 +3678 +6511 +1133 +29999 +2594 +3881 +3498 +8732 +2378 +3394 +3393 +2298 +2297 +9388 +9387 +3120 +3297 +1898 +8442 +9888 +4183 +4673 +3778 +5271 +3127 +1932 +4451 +2563 +4452 +9346 +7022 +3631 +3630 +105 +3271 +2699 +3004 +2129 +4187 +1724 +3113 +2314 +8380 +8377 +8376 +8379 +8378 +20810 +3818 +41797 +41796 +38002 +3364 +3366 +2824 +2823 +3609 +4055 +4054 +4053 +2654 +19220 +9093 +3183 +2565 +4078 +4774 +2153 +17222 +7551 +7563 +3072 +4047 +9695 +4846 +5992 +5683 +4692 +3191 +3417 +7169 +3973 +46998 +16384 +3947 +47100 +6970 +2491 +7023 +10321 +42508 +3822 +2417 +2555 +3257 +3256 +22343 +64 +7215 +20003 +4450 +3751 +3605 +2534 +3490 +4419 +7689 +21213 +7574 +3377 +3779 +44444 +3039 +2415 +2183 +26257 +3576 +3575 +2976 +7168 +8501 +164 +3384 +7550 +45514 +356 +2617 +3730 +6688 +6687 +6690 +7683 +2052 +3481 +4136 +4137 +9087 +172 +1729 +4980 +7229 +7228 +24754 +2897 +7279 +2512 +2513 +4870 +22305 +5787 +6633 +131 +15555 +4051 +4785 +43441 +5784 +7546 +8017 +3887 +5194 +1743 +2891 +3770 +1377 +4316 +4314 +3099 +1572 +39063 +1891 +1892 +3349 +18241 +18243 +18242 +18185 +5505 +6556 +562 +531 +3772 +5065 +5064 +2182 +3893 +2921 +2922 +13832 +4074 +4140 +4115 +3056 +3616 +3559 +4970 +4969 +3114 +3750 +12168 +2122 +7129 +7162 +7167 +5270 +1197 +9060 +3106 +12546 +5247 +5246 +3290 +4728 +8998 +8610 +8609 +3756 +8614 +8613 +8612 +8611 +1872 +3583 +24676 +4377 +5079 +4378 +1734 +3545 +7262 +3675 +2552 +22537 +3709 +14414 +5251 +1882 +42509 +2318 +4326 +1563 +7163 +1554 +7161 +595 +348 +282 +8026 +5249 +5248 +5154 +10880 +3626 +4990 +3107 +6410 +6409 +6408 +6407 +6406 +6405 +6404 +4677 +581 +4671 +2964 +2965 +28589 +47808 +3966 +2446 +1854 +1961 +2444 +2277 +4175 +3188 +3043 +9380 +3692 +5682 +2155 +4104 +4103 +4102 +3593 +2845 +2844 +4186 +2218 +4678 +2017 +2913 +7648 +4914 +7687 +6501 +9750 +3344 +1896 +4568 +10128 +6768 +6767 +3182 +1313 +3181 +2059 +3604 +6300 +10129 +3695 +6301 +2494 +2625 +48129 +8195 +2369 +2574 +5750 +13823 +13216 +4027 +5068 +25955 +25954 +6946 +3411 +24577 +5429 +2259 +4621 +6784 +4676 +4675 +4784 +3785 +5425 +5424 +4305 +3960 +3408 +5584 +5585 +1943 +3124 +6508 +6507 +4155 +1120 +1929 +4324 +10439 +6506 +6505 +6122 +4971 +3387 +152 +2635 +2169 +6696 +2204 +3512 +2071 +10260 +35100 +4195 +3277 +3502 +2066 +2238 +4413 +20057 +2992 +2050 +3965 +10990 +31020 +4685 +1140 +7508 +16003 +4071 +3104 +3437 +5067 +33123 +1146 +44600 +2264 +7543 +2419 +32896 +2317 +3821 +4937 +1520 +11367 +4154 +3617 +20999 +1170 +1171 +2864 +27876 +4485 +4704 +7235 +3087 +45000 +4405 +4404 +4406 +4402 +4403 +4400 +5727 +11489 +2192 +4077 +4448 +3581 +5150 +13702 +3451 +386 +8211 +7166 +3518 +27782 +3176 +9292 +3174 +9295 +9294 +3426 +8423 +3140 +7570 +421 +2114 +6344 +2581 +2582 +11321 +384 +23546 +1834 +1115 +4165 +1557 +3758 +7847 +5086 +4849 +2037 +1447 +3312 +187 +4488 +2336 +387 +208 +207 +203 +3454 +10548 +4674 +38203 +3239 +3236 +3237 +3238 +4573 +2758 +10252 +2759 +8121 +2754 +8122 +3184 +42999 +539 +6082 +18888 +9952 +9951 +7846 +7845 +6549 +5456 +5455 +5454 +4851 +5913 +5072 +3939 +2247 +1206 +3715 +2646 +3054 +5671 +8040 +376 +2640 +30004 +30003 +5192 +4393 +4392 +4391 +4394 +1931 +5506 +8301 +4563 +35355 +4011 +7799 +3265 +9209 +693 +36001 +9956 +9955 +6627 +3234 +2667 +2668 +3613 +4804 +2887 +3416 +3833 +9216 +2846 +17555 +2786 +3316 +3021 +3026 +4878 +3917 +4362 +7775 +3224 +23457 +23456 +4549 +4431 +2295 +3573 +5073 +3760 +3357 +3954 +3705 +3704 +2692 +6769 +33890 +7170 +2521 +2085 +3096 +2810 +2859 +3431 +9389 +3655 +5106 +5103 +44445 +7509 +6801 +4013 +2476 +2475 +2334 +12007 +12008 +6868 +4046 +18463 +32483 +4030 +8793 +62 +1955 +3781 +3619 +3618 +28119 +4726 +4502 +4597 +4598 +3598 +3597 +3125 +4149 +9953 +23294 +2933 +2934 +5783 +5782 +5785 +5781 +15363 +48049 +2339 +5265 +5264 +1181 +3446 +3428 +15998 +3091 +2133 +3774 +317 +3832 +508 +3721 +1619 +1716 +2279 +3412 +2327 +6558 +2130 +1760 +5413 +2396 +2923 +3378 +3466 +2504 +2720 +4871 +7395 +3926 +1727 +1326 +2518 +1890 +2781 +565 +4984 +3342 +21845 +1963 +2851 +3748 +1739 +1269 +2455 +2547 +2548 +2546 +7779 +2695 +312 +2996 +2893 +1589 +2649 +1224 +1345 +3625 +2538 +3321 +175 +1868 +4344 +1853 +3058 +3802 +78 +2770 +3270 +575 +1771 +4839 +4838 +4837 +671 +430 +431 +2745 +2648 +3356 +1957 +2820 +1978 +2927 +2499 +2437 +2138 +2110 +1797 +1737 +483 +390 +1867 +1624 +1833 +2879 +2767 +2768 +2943 +1568 +2489 +1237 +2741 +2742 +8804 +1588 +6069 +1869 +2642 +20670 +594 +2885 +2669 +476 +2798 +3083 +3082 +3081 +2361 +5104 +1758 +7491 +1728 +5428 +1946 +559 +1610 +3144 +1922 +2726 +6149 +1838 +4014 +1274 +2647 +4106 +6102 +4548 +19540 +1866 +6965 +6966 +6964 +6963 +1751 +1625 +5453 +2709 +7967 +3354 +566 +4178 +2986 +1226 +1836 +1654 +2838 +1692 +3644 +6071 +477 +478 +2507 +1923 +3193 +2653 +2636 +1621 +3379 +2533 +2892 +2452 +1684 +2333 +22000 +1553 +3536 +11201 +2775 +2942 +2941 +2940 +2939 +2938 +2613 +426 +4116 +4412 +1966 +3065 +1225 +1705 +1618 +1660 +2545 +2676 +3687 +2756 +1599 +2832 +2831 +2830 +2829 +5461 +2974 +498 +1626 +3595 +160 +153 +3326 +1714 +3172 +3173 +3171 +3170 +3169 +2235 +6108 +169 +5399 +2471 +558 +2308 +1681 +2385 +3562 +5024 +5025 +5427 +3391 +3744 +1646 +3275 +3698 +2390 +1793 +1647 +1697 +1693 +1695 +1696 +2919 +9599 +2423 +3844 +2959 +2818 +1817 +521 +3147 +3163 +2886 +283 +2837 +2543 +2928 +2240 +1343 +2321 +3467 +9753 +1530 +2872 +1595 +2900 +1341 +2935 +3059 +2724 +3385 +2765 +368 +2461 +2462 +1253 +2680 +3009 +2434 +2694 +2351 +2353 +2354 +1788 +2352 +3662 +2355 +2091 +1732 +8183 +1678 +2588 +2924 +2687 +5071 +1777 +2899 +494 +3875 +2937 +5437 +5436 +3469 +3285 +1293 +5272 +2865 +321 +1280 +1779 +6432 +1230 +2843 +3033 +2566 +1562 +3085 +3892 +1246 +1564 +8160 +1633 +9997 +9996 +7511 +5236 +3955 +2956 +2954 +2953 +5310 +2951 +2936 +6951 +2413 +2407 +1597 +1570 +2398 +1809 +1575 +1754 +1748 +22001 +3855 +2368 +8764 +6653 +5314 +2267 +3244 +2661 +2364 +506 +2322 +2498 +3305 +183 +650 +2329 +5991 +1463 +159 +8450 +1917 +1921 +2839 +2503 +25903 +25901 +25902 +2556 +2672 +1690 +2360 +2671 +1669 +1665 +1286 +4138 +2592 +61441 +61439 +61440 +2983 +5465 +1843 +1842 +1841 +2061 +1329 +2451 +3701 +3066 +2442 +5771 +2450 +489 +8834 +1285 +3262 +2881 +2883 +43189 +6064 +1591 +1744 +405 +2397 +2683 +2162 +1288 +2286 +2236 +167 +1685 +1831 +2981 +467 +1574 +2743 +19398 +2469 +2460 +1477 +1478 +5720 +3535 +1582 +1731 +679 +2684 +2686 +2681 +2685 +1952 +9397 +9344 +2952 +2579 +2561 +1235 +367 +8665 +471 +2926 +1815 +7786 +8033 +1581 +7979 +1534 +490 +3070 +349 +1824 +2511 +1897 +6070 +2118 +2117 +1231 +24003 +24004 +24006 +24000 +3594 +24002 +24001 +24005 +5418 +2698 +8763 +1820 +1899 +2587 +8911 +8910 +1593 +2535 +4181 +3565 +2559 +3069 +2620 +1298 +2540 +2541 +2125 +1487 +2283 +2284 +2285 +2281 +2282 +2813 +5355 +2814 +2795 +1555 +1968 +2611 +245 +4042 +1682 +1485 +2560 +2841 +2370 +2842 +2840 +398 +2424 +1773 +1649 +287 +2656 +2213 +2822 +1289 +3471 +3470 +3042 +4114 +6962 +6961 +1567 +2808 +1706 +2406 +2508 +2506 +1623 +13160 +2166 +2866 +2982 +1275 +1573 +4348 +1828 +3084 +1609 +2853 +3589 +147 +3501 +1643 +1642 +1245 +43190 +2962 +2963 +576 +2549 +1579 +1585 +503 +1907 +3202 +3548 +3060 +2652 +2633 +16991 +495 +1602 +1490 +2793 +18881 +2854 +2319 +2233 +3345 +2454 +8130 +8131 +2127 +2970 +2932 +3164 +1710 +11319 +27345 +2801 +1284 +2995 +3797 +2966 +2590 +549 +1725 +2337 +3130 +5813 +25008 +25007 +25006 +25005 +25004 +25003 +25002 +25009 +6850 +1344 +1604 +8733 +2572 +1260 +1586 +1726 +6999 +6998 +2140 +2139 +2141 +1577 +4180 +4827 +1877 +2715 +19412 +19410 +19411 +5404 +5403 +2985 +1803 +2744 +6790 +2575 +12172 +1789 +35000 +1281 +14937 +14936 +263 +375 +5094 +1816 +2245 +1238 +2778 +9321 +2643 +2421 +488 +1850 +2458 +41 +2519 +6109 +1774 +2833 +3862 +3381 +1590 +2626 +1738 +2732 +19539 +2849 +2358 +1786 +1787 +1657 +2429 +1747 +1746 +5408 +5407 +2359 +24677 +1874 +2946 +2509 +1873 +2747 +2751 +2750 +2748 +2749 +9396 +3067 +1848 +9374 +2510 +2615 +1689 +4682 +3350 +24242 +3401 +3294 +3293 +5503 +5504 +5746 +5745 +2344 +7437 +3353 +2689 +3873 +1561 +1915 +2792 +10103 +26260 +26261 +589 +1948 +2666 +26489 +26487 +2769 +2674 +6066 +1876 +2835 +2834 +2782 +16309 +2969 +2867 +2797 +2950 +1822 +1342 +5135 +2650 +2109 +2051 +2912 +309 +1865 +3289 +1804 +3286 +1740 +2211 +2707 +1273 +2181 +2553 +2896 +2858 +3610 +2651 +1325 +2445 +1265 +3053 +1292 +1878 +4098 +1780 +1795 +4099 +1821 +2151 +1227 +436 +2287 +32636 +1489 +1263 +5419 +3041 +2496 +3287 +6073 +2234 +242 +1844 +2362 +11112 +1941 +3046 +1945 +6072 +2960 +5426 +2753 +3298 +1702 +1256 +1254 +1266 +2562 +1656 +1655 +579 +1255 +1415 +2365 +2345 +6104 +8132 +1908 +3282 +1857 +1679 +2870 +3458 +5420 +772 +3645 +551 +1686 +3773 +4379 +1851 +3022 +2807 +2890 +1837 +2955 +3145 +1471 +1468 +40841 +40842 +40843 +2422 +6253 +455 +2746 +3201 +5984 +2324 +3288 +5412 +2137 +1648 +1802 +4308 +48556 +2757 +1757 +1294 +7174 +1944 +371 +504 +1741 +2931 +3020 +17219 +3903 +1768 +1767 +1766 +1765 +2856 +1640 +1639 +1794 +3987 +2571 +2412 +3315 +2116 +3061 +2836 +3450 +3105 +1756 +9283 +2906 +588 +1202 +1375 +2803 +2536 +1252 +2619 +1323 +2990 +1304 +2961 +6402 +6403 +3561 +1770 +1769 +2877 +10288 +2911 +2032 +2663 +2662 +1962 +310 +357 +354 +482 +2414 +2852 +1951 +1704 +3327 +573 +567 +2708 +2131 +2772 +3643 +1749 +5042 +1913 +2624 +1826 +2136 +2616 +9164 +9163 +9162 +1781 +2929 +1320 +2848 +2268 +459 +1536 +2639 +6831 +10080 +1845 +1653 +1849 +463 +2740 +2473 +2783 +1481 +2785 +2331 +7107 +1219 +3279 +5411 +2796 +2149 +7781 +1205 +4108 +4885 +1546 +2894 +1601 +2878 +5605 +5604 +5602 +5603 +3284 +1742 diff --git a/bbot/wordlists/valid_url_schemes.txt b/bbot/wordlists/valid_url_schemes.txt new file mode 100644 index 000000000..f0a440da9 --- /dev/null +++ b/bbot/wordlists/valid_url_schemes.txt @@ -0,0 +1,382 @@ +aaa +awb +aaas +about +acap +acct +acd +acr +adiumxtra +adt +afp +afs +aim +amss +android +appdata +apt +ar +ark +at +attachment +aw +barion +bb +beshare +bitcoin +bitcoincash +blob +bolo +brid +browserext +cabal +calculator +callto +cap +cast +casts +chrome +chrome-extension +cid +coap +coap+tcp +coap+ws +coaps +coaps+tcp +coaps+ws +com-eventbrite-attendee +content +content-type +crid +cstr +cvs +dab +dat +data +dav +dhttp +diaspora +dict +did +dis +dlna-playcontainer +dlna-playsingle +dns +dntp +doi +dpp +drm +drop +dtmi +dtn +dvb +dvx +dweb +ed2k +eid +elsi +embedded +ens +ethereum +example +facetime +fax +feed +feedready +fido +file +filesystem +finger +first-run-pen-experience +fish +fm +ftp +fuchsia-pkg +geo +gg +git +gitoid +gizmoproject +go +gopher +graph +grd +gtalk +h323 +ham +hcap +hcp +hs20 +http +https +hxxp +hxxps +hydrazone +hyper +iax +icap +icon +im +imap +info +iotdisco +ipfs +ipn +ipns +ipp +ipps +irc +irc6 +ircs +iris +iris.beep +iris.lwz +iris.xpc +iris.xpcs +isostore +itms +jabber +jar +jms +keyparc +lastfm +lbry +ldap +ldaps +leaptofrogans +lid +lorawan +lpa +lvlt +machineProvisioningProgressReporter +magnet +mailserver +mailto +maps +market +matrix +message +microsoft.windows.camera +microsoft.windows.camera.multipicker +microsoft.windows.camera.picker +mid +mms +modem +mongodb +moz +ms-access +ms-appinstaller +ms-browser-extension +ms-calculator +ms-drive-to +ms-enrollment +ms-excel +ms-eyecontrolspeech +ms-gamebarservices +ms-gamingoverlay +ms-getoffice +ms-help +ms-infopath +ms-inputapp +ms-launchremotedesktop +ms-lockscreencomponent-config +ms-media-stream-id +ms-meetnow +ms-mixedrealitycapture +ms-mobileplans +ms-newsandinterests +ms-officeapp +ms-people +ms-project +ms-powerpoint +ms-publisher +ms-recall +ms-remotedesktop +ms-remotedesktop-launch +ms-restoretabcompanion +ms-screenclip +ms-screensketch +ms-search +ms-search-repair +ms-secondary-screen-controller +ms-secondary-screen-setup +ms-settings +ms-settings-airplanemode +ms-settings-bluetooth +ms-settings-camera +ms-settings-cellular +ms-settings-cloudstorage +ms-settings-connectabledevices +ms-settings-displays-topology +ms-settings-emailandaccounts +ms-settings-language +ms-settings-location +ms-settings-lock +ms-settings-nfctransactions +ms-settings-notifications +ms-settings-power +ms-settings-privacy +ms-settings-proximity +ms-settings-screenrotation +ms-settings-wifi +ms-settings-workplace +ms-spd +ms-stickers +ms-sttoverlay +ms-transit-to +ms-useractivityset +ms-virtualtouchpad +ms-visio +ms-walk-to +ms-whiteboard +ms-whiteboard-cmd +ms-word +msnim +msrp +msrps +mss +mt +mtqp +mumble +mupdate +mvn +mvrp +mvrps +news +nfs +ni +nih +nntp +notes +num +ocf +oid +onenote +onenote-cmd +opaquelocktoken +openid +openpgp4fpr +otpauth +p1 +pack +palm +paparazzi +payment +payto +pkcs11 +platform +pop +pres +prospero +proxy +pwid +psyc +pttp +qb +query +quic-transport +redis +rediss +reload +res +resource +rmi +rsync +rtmfp +rtmp +rtsp +rtsps +rtspu +sarif +secondlife +secret-token +service +session +sftp +sgn +shc +shttp +sieve +simpleledger +simplex +sip +sips +skype +smb +smp +sms +smtp +snews +snmp +soap.beep +soap.beeps +soldat +spiffe +spotify +ssb +ssh +starknet +steam +stun +stuns +submit +svn +swh +swid +swidpath +tag +taler +teamspeak +tel +teliaeid +telnet +tftp +things +thismessage +tip +tn3270 +tool +turn +turns +tv +udp +unreal +upt +urn +ut2004 +uuid-in-package +v-event +vemmi +ventrilo +ves +videotex +vnc +view-source +vscode +vscode-insiders +vsls +w3 +wais +web3 +wcr +webcal +web+ap +wifi +wpid +ws +wss +wtai +wyciwyg +xcon +xcon-userid +xfire +xmlrpc.beep +xmlrpc.beeps +xmpp +xftp +xrcp +xri +ymsgr +z39.50 +z39.50r +z39.50s \ No newline at end of file diff --git a/docs/comparison.md b/docs/comparison.md index 3226036f1..183e84319 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -2,7 +2,7 @@ BBOT does a lot more than just subdomain enumeration. However, subdomain enumeration is arguably the most important part of OSINT, and since there's so many subdomain enumeration tools out there, they're the easiest class of tool to compare it to. -Thanks to BBOT's recursive nature (and its `massdns` module with its NLP-powered subdomain mutations), it typically finds about 20-25% more than other tools such as `Amass` or `theHarvester`. This holds true even for larger targets like `delta.com` (1000+ subdomains): +Thanks to BBOT's recursive nature (and its `dnsbrute_mutations` module with its NLP-powered subdomain mutations), it typically finds about 20-25% more than other tools such as `Amass` or `theHarvester`. This holds true especially for larger targets like `delta.com` (1000+ subdomains): ### Subdomains Found diff --git a/docs/contribution.md b/docs/contribution.md index 58b1b45e8..b291cea68 100644 --- a/docs/contribution.md +++ b/docs/contribution.md @@ -2,214 +2,8 @@ We welcome contributions! If you have an idea for a new module, or are a Python developer who wants to get involved, please fork us or come talk to us on [Discord](https://discord.com/invite/PZqkgxu5SA). -## Setting Up a Dev Environment +To get started devving, see the following links: -### Installation (Poetry) - -[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: - -- Fork [BBOT](https://github.com/blacklanternsecurity/bbot) on GitHub -- Clone your fork and set up a development environment with Poetry: - -```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 pip dependencies -poetry install -# install pre-commit hooks, etc. -poetry run pre-commit install - -# enter virtual environment -poetry shell - -bbot --help -``` - -- Now, any changes you make in the code will be reflected in the `bbot` command. -- After making your changes, run the tests locally to ensure they pass. - -```bash -# auto-format code indentation, etc. -black . - -# run tests -./bbot/test/run_tests.sh -``` - -- Finally, commit and push your changes, and create a pull request to the `dev` branch of the main BBOT repo. - - -## Creating a Module - -Writing a module is easy and requires only a basic understanding of Python. It consists of a few steps: - -1. Create a new `.py` file in `bbot/modules` -1. At the top of the file, import `BaseModule` -1. Declare a class that inherits from `BaseModule` - - the class must have the same name as your file (case-insensitive) -1. Define in `watched_events` what type of data your module will consume -1. Define in `produced_events` what type of data your module will produce -1. Define (via `flags`) whether your module is `active` or `passive`, and whether it's `safe` or `aggressive` -1. **Put your main logic in `.handle_event()`** - -Here is an example of a simple module that performs whois lookups: - -```python title="bbot/modules/whois.py" -from bbot.modules.base import BaseModule - -class whois(BaseModule): - watched_events = ["DNS_NAME"] # watch for DNS_NAME events - produced_events = ["WHOIS"] # we produce WHOIS events - flags = ["passive", "safe"] - meta = {"description": "Query WhoisXMLAPI for WHOIS data"} - options = {"api_key": ""} # module config options - options_desc = {"api_key": "WhoisXMLAPI Key"} - per_domain_only = True # only run once per domain - - base_url = "https://www.whoisxmlapi.com/whoisserver/WhoisService" - - # one-time setup - runs at the beginning of the scan - async def setup(self): - self.api_key = self.config.get("api_key") - if not self.api_key: - # soft-fail if no API key is set - return None, "Must set API key" - - async def handle_event(self, event): - self.hugesuccess(f"Got {event} (event.data: {event.data})") - _, domain = self.helpers.split_domain(event.data) - url = f"{self.base_url}?apiKey={self.api_key}&domainName={domain}&outputFormat=JSON" - self.hugeinfo(f"Visiting {url}") - response = await self.helpers.request(url) - if response is not None: - await self.emit_event(response.json(), "WHOIS", source=event) -``` - -After saving the module, you can run it with `-m`: - -```bash -# run a scan enabling the module in bbot/modules/mymodule.py -bbot -t evilcorp.com -m whois -``` - -### `handle_event()` and `emit_event()` - -The `handle_event()` method is the most important part of the module. By overriding this method, you control what the module does. During a scan, when an [event](./scanning/events.md) from your `watched_events` is encountered (a `DNS_NAME` in this example), `handle_event()` is automatically called with that event as its argument. - -The `emit_event()` method is how modules return data. When you call `emit_event()`, it creates an [event](./scanning/events.md) and outputs it, sending it any modules that are interested in that data type. - -### `setup()` - -A module's `setup()` method is used for performing one-time setup at the start of the scan, like downloading a wordlist or checking to make sure an API key is valid. It needs to return either: - -1. `True` - module setup succeeded -2. `None` - module setup soft-failed (scan will continue but module will be disabled) -3. `False` - module setup hard-failed (scan will abort) - -Optionally, it can also return a reason. Here are some examples: - -```python -async def setup(self): - if not self.config.get("api_key"): - # soft-fail - return None, "No API key specified" - -async def setup(self): - try: - wordlist = self.helpers.wordlist("https://raw.githubusercontent.com/user/wordlist.txt") - except WordlistError as e: - # hard-fail - return False, f"Error downloading wordlist: {e}" - -async def setup(self): - self.timeout = self.config.get("timeout", 5) - # success - return True -``` - -### Module Config Options - -Each module can have its own set of config options. These live in the `options` and `options_desc` attributes on your class. Both are dictionaries; `options` is for defaults and `options_desc` is for descriptions. Here is a typical example: - -```python title="bbot/modules/nmap.py" -class nmap(BaseModule): - # ... - options = { - "top_ports": 100, - "ports": "", - "timing": "T4", - "skip_host_discovery": True, - } - options_desc = { - "top_ports": "Top ports to scan (default 100) (to override, specify 'ports')", - "ports": "Ports to scan", - "timing": "-T<0-5>: Set timing template (higher is faster)", - "skip_host_discovery": "skip host discovery (-Pn)", - } - - async def setup(self): - self.ports = self.config.get("ports", "") - self.timing = self.config.get("timing", "T4") - self.top_ports = self.config.get("top_ports", 100) - self.skip_host_discovery = self.config.get("skip_host_discovery", True) -``` - -Once you've defined these variables, you can pass the options via `-c`: - -```bash -bbot -m nmap -c modules.nmap.top_ports=250 -``` - -... or via the config: - -```yaml title="~/.config/bbot/bbot.yml" -modules: - nmap: - top_ports: 250 -``` - -Inside the module, you access them via `self.config`, e.g.: - -```python -self.config.get("top_ports") -``` - -### Module Dependencies - -BBOT automates module dependencies with **Ansible**. If your module relies on a third-party binary, OS package, or python library, you can specify them in the `deps_*` attributes of your module. - -```python -class MyModule(BaseModule): - ... - deps_apt = ["chromium-browser"] - deps_ansible = [ - { - "name": "install dev tools", - "package": {"name": ["gcc", "git", "make"], "state": "present"}, - "become": True, - "ignore_errors": True, - }, - { - "name": "Download massdns source code", - "git": { - "repo": "https://github.com/blechschmidt/massdns.git", - "dest": "#{BBOT_TEMP}/massdns", - "single_branch": True, - "version": "master", - }, - }, - { - "name": "Build massdns", - "command": {"chdir": "#{BBOT_TEMP}/massdns", "cmd": "make", "creates": "#{BBOT_TEMP}/massdns/bin/massdns"}, - }, - { - "name": "Install massdns", - "copy": {"src": "#{BBOT_TEMP}/massdns/bin/massdns", "dest": "#{BBOT_TOOLS}/", "mode": "u+x,g+x,o+x"}, - }, - ] -``` +- [Setting up a Dev Environment](./dev/dev_environment.md) +- [How to Write a BBOT Module](./dev/module_howto.md) +- [Discord Bot Example](./dev/discord_bot.md) diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md new file mode 100644 index 000000000..a2547154f --- /dev/null +++ b/docs/dev/architecture.md @@ -0,0 +1,17 @@ +# BBOT Internal Architecture + +Here is a basic overview of BBOT's internal architecture. + +## Queues + +Being both ***recursive*** and ***event-driven***, BBOT makes heavy use of queues. These enable smooth communication between the modules, and ensure that large numbers of events can be produced without slowing down or clogging up the scan. + +Every module in BBOT has both an ***incoming*** and ***outgoing*** queue. Event types matching the module's `WATCHED_EVENTS` (e.g. `DNS_NAME`) are queued in its incoming queue, and processed by the module's `handle_event()` (or `handle_batch()` in the case of batched modules). If the module finds anything interesting, it creates an event and places it in its outgoing queue, to be processed by the scan and redistributed to other modules. + +## Event Flow + +Below is a graph showing the internal event flow in BBOT. White lines represent queues. Notice how some modules run in sequence, while others run in parallel. With the exception of a few specific modules, most BBOT modules are parallelized. + +![event-flow](https://github.com/blacklanternsecurity/bbot/assets/20261699/6cece76b-70bd-4690-a53f-02d42e6ed05b) + +For a higher-level overview, see [How it Works](../how_it_works.md). diff --git a/docs/dev/core.md b/docs/dev/core.md new file mode 100644 index 000000000..d138681f9 --- /dev/null +++ b/docs/dev/core.md @@ -0,0 +1 @@ +::: bbot.core.core.BBOTCore diff --git a/docs/dev/dev_environment.md b/docs/dev/dev_environment.md new file mode 100644 index 000000000..054656150 --- /dev/null +++ b/docs/dev/dev_environment.md @@ -0,0 +1,40 @@ +## Setting Up a Dev Environment + +### Installation (Poetry) + +[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: + +- Fork [BBOT](https://github.com/blacklanternsecurity/bbot) on GitHub +- Clone your fork and set up a development environment with Poetry: + +```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 pip dependencies +poetry install +# install pre-commit hooks, etc. +poetry run pre-commit install + +# enter virtual environment +poetry shell + +bbot --help +``` + +- Now, any changes you make in the code will be reflected in the `bbot` command. +- After making your changes, run the tests locally to ensure they pass. + +```bash +# auto-format code indentation, etc. +black . + +# run tests +./bbot/test/run_tests.sh +``` + +- Finally, commit and push your changes, and create a pull request to the `dev` branch of the main BBOT repo. diff --git a/docs/dev/discord_bot.md b/docs/dev/discord_bot.md new file mode 100644 index 000000000..ff2aa860a --- /dev/null +++ b/docs/dev/discord_bot.md @@ -0,0 +1,8 @@ + +![bbot-discord](https://github.com/blacklanternsecurity/bbot/assets/20261699/22b268a2-0dfd-4c2a-b7c5-548c0f2cc6f9) + +Below is a simple Discord bot designed to run BBOT scans. + +```python title="examples/discord_bot.py" +--8<-- "examples/discord_bot.py" +``` diff --git a/docs/dev/engine.md b/docs/dev/engine.md new file mode 100644 index 000000000..d77bd3970 --- /dev/null +++ b/docs/dev/engine.md @@ -0,0 +1,5 @@ +::: bbot.core.engine.EngineBase + +::: bbot.core.engine.EngineClient + +::: bbot.core.engine.EngineServer diff --git a/docs/dev/helpers/command.md b/docs/dev/helpers/command.md index b2b9171f5..3716d2037 100644 --- a/docs/dev/helpers/command.md +++ b/docs/dev/helpers/command.md @@ -1,6 +1,6 @@ # Command Helpers -These are helpers related to executing shell commands. They are used throughout BBOT and its modules for executing various binaries such as `nmap`, `nuclei`, etc. +These are helpers related to executing shell commands. They are used throughout BBOT and its modules for executing various binaries such as `masscan`, `nuclei`, etc. These helpers can be invoked directly from `self.helpers`, but inside a module they should always use `self.run_process()` or `self.run_process_live()`. These are light wrappers which ensure the running process is tracked by the module so that it can be easily terminated should the user need to kill the module: diff --git a/docs/dev/index.md b/docs/dev/index.md index 093a3aefb..526f03ce9 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -1,96 +1,86 @@ # BBOT Developer Reference -BBOT exposes a convenient API that allows you to create, start, and stop scans using Python code. +BBOT exposes a Python API that allows you to create, start, and stop scans. Documented in this section are commonly-used classes and functions within BBOT, along with usage examples. -## Discord Bot Example +## Running a BBOT Scan from Python -![bbot-discord](https://github.com/blacklanternsecurity/bbot/assets/20261699/22b268a2-0dfd-4c2a-b7c5-548c0f2cc6f9) +#### Synchronous +```python +from bbot.scanner import Scanner -Below is a simple Discord bot designed to run BBOT scans. +scan = Scanner("evilcorp.com", presets=["subdomain-enum"]) +for event in scan.start(): + print(event) +``` +#### Asynchronous ```python +from bbot.scanner import Scanner + +async def main(): + scan = Scanner("evilcorp.com", presets=["subdomain-enum"]) + async for event in scan.async_start(): + print(event.json()) + import asyncio -import discord -from discord.ext import commands +asyncio.run(main()) +``` -from bbot.scanner import Scanner -from bbot.modules import module_loader -from bbot.modules.output.discord import Discord - - -# make list of BBOT modules to enable for the scan -bbot_modules = ["excavate", "speculate", "aggregate"] -for module_name, preloaded in module_loader.preloaded().items(): - flags = preloaded["flags"] - if "subdomain-enum" in flags and "passive" in flags and "slow" not in flags: - bbot_modules.append(module_name) - - -class BBOTDiscordBot(commands.Cog): - """ - A simple Discord bot capable of running a BBOT scan. - - To set up: - 1. Go to Discord Developer Portal (https://discord.com/developers) - 2. Create a new application - 3. Create an invite link for the bot, visit the link to invite it to your server - - Your Application --> OAuth2 --> URL Generator - - For Scopes, select "bot"" - - For Bot Permissions, select: - - Read Messages/View Channels - - Send Messages - 4. Turn on "Message Content Intent" - - Your Application --> Bot --> Privileged Gateway Intents --> Message Content Intent - 5. Copy your Discord Bot Token and put it at the top this file - - Your Application --> Bot --> Reset Token - 6. Run this script - - To scan evilcorp.com, you would type: - - /scan evilcorp.com - - Results will be output to the same channel. - """ - def __init__(self): - self.current_scan = None - - @commands.command(name="scan", description="Scan a target with BBOT.") - async def scan(self, ctx, target: str): - if self.current_scan is not None: - self.current_scan.stop() - await ctx.send(f"Starting scan against {target}.") - - # creates scan instance - self.current_scan = Scanner(target, modules=bbot_modules) - discord_module = Discord(self.current_scan) - - seen = set() - num_events = 0 - # start scan and iterate through results - async for event in self.current_scan.async_start(): - if hash(event) in seen: - continue - seen.add(hash(event)) - await ctx.send(discord_module.format_message(event)) - num_events += 1 - - await ctx.send(f"Finished scan against {target}. {num_events:,} results.") - self.current_scan = None - - -if __name__ == "__main__": - intents = discord.Intents.default() - intents.message_content = True - bot = commands.Bot(command_prefix="/", intents=intents) - - @bot.event - async def on_ready(): - print(f"We have logged in as {bot.user}") - await bot.add_cog(BBOTDiscordBot()) - - bot.run("DISCORD_BOT_TOKEN_HERE") +For a full listing of `Scanner` attributes and functions, see the [`Scanner` Code Reference](./scanner.md). + +#### Multiple Targets + +You can specify any number of targets: + +```python +# create a scan against multiple targets +scan = Scanner( + "evilcorp.com", + "evilcorp.org", + "evilcorp.ce", + "4.3.2.1", + "1.2.3.4/24", + presets=["subdomain-enum"] +) + +# this is the same as: +targets = ["evilcorp.com", "evilcorp.org", "evilcorp.ce", "4.3.2.1", "1.2.3.4/24"] +scan = Scanner(*targets, presets=["subdomain-enum"]) +``` + +For more details, including which types of targets are valid, see [Targets](../scanning/index.md#targets) + +#### 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`](../index.md#flags), [`modules`](../index.md#modules), [`output_modules`](../output.md), a [`whitelist` or `blacklist`](../scanning/index.md#whitelists-and-blacklists), and custom [`config` options](../scanning/configuration.md): + +```python +# create a scan against multiple targets +scan = Scanner( + # targets + "evilcorp.com", + "4.3.2.1", + # enable these presets + presets=["subdomain-enum"], + # whitelist these hosts + whitelist=["evilcorp.com", "evilcorp.org"], + # blacklist these hosts + blacklist=["prod.evilcorp.com"], + # also enable these individual modules + modules=["nuclei", "ipstack"], + # exclude modules with these flags + exclude_flags=["slow"], + # custom config options + config={ + "modules": { + "nuclei": { + "tags": "apache,nginx" + } + } + } +) ``` -[Next Up: Scanner -->](scanner.md){ .md-button .md-button--primary } +For a list of all the possible scan options, see the [`Presets` Code Reference](./presets.md) diff --git a/docs/dev/module_howto.md b/docs/dev/module_howto.md new file mode 100644 index 000000000..e3a3d0cbf --- /dev/null +++ b/docs/dev/module_howto.md @@ -0,0 +1,176 @@ +# How to Write a BBOT Module + +Here we'll go over a basic example of writing a custom BBOT module. + +## Create the python file + +1. Create a new `.py` file in `bbot/modules` +1. At the top of the file, import `BaseModule` +1. Declare a class that inherits from `BaseModule` + - the class must have the same name as your file (case-insensitive) +1. Define in `watched_events` what type of data your module will consume +1. Define in `produced_events` what type of data your module will produce +1. Define (via `flags`) whether your module is `active` or `passive`, and whether it's `safe` or `aggressive` +1. **Put your main logic in `.handle_event()`** + +Here is an example of a simple module that performs whois lookups: + +```python title="bbot/modules/whois.py" +from bbot.modules.base import BaseModule + +class whois(BaseModule): + watched_events = ["DNS_NAME"] # watch for DNS_NAME events + produced_events = ["WHOIS"] # we produce WHOIS events + flags = ["passive", "safe"] + meta = {"description": "Query WhoisXMLAPI for WHOIS data"} + options = {"api_key": ""} # module config options + options_desc = {"api_key": "WhoisXMLAPI Key"} + per_domain_only = True # only run once per domain + + base_url = "https://www.whoisxmlapi.com/whoisserver/WhoisService" + + # one-time setup - runs at the beginning of the scan + async def setup(self): + self.api_key = self.config.get("api_key") + if not self.api_key: + # soft-fail if no API key is set + return None, "Must set API key" + + async def handle_event(self, event): + self.hugesuccess(f"Got {event} (event.data: {event.data})") + _, domain = self.helpers.split_domain(event.data) + url = f"{self.base_url}?apiKey={self.api_key}&domainName={domain}&outputFormat=JSON" + self.hugeinfo(f"Visiting {url}") + response = await self.helpers.request(url) + if response is not None: + await self.emit_event(response.json(), "WHOIS", parent=event) +``` + +## Test your new module + +After saving the module, you can run it with `-m`: + +```bash +# run a scan enabling the module in bbot/modules/mymodule.py +bbot -t evilcorp.com -m whois +``` + +For details on how tests are written, see [Unit Tests](./tests.md). + +## `handle_event()` and `emit_event()` + +The `handle_event()` method is the most important part of the module. By overriding this method, you control what the module does. During a scan, when an [event](./scanning/events.md) from your `watched_events` is encountered (a `DNS_NAME` in this example), `handle_event()` is automatically called with that event as its argument. + +The `emit_event()` method is how modules return data. When you call `emit_event()`, it creates an [event](./scanning/events.md) and outputs it, sending it any modules that are interested in that data type. + +## `setup()` + +A module's `setup()` method is used for performing one-time setup at the start of the scan, like downloading a wordlist or checking to make sure an API key is valid. It needs to return either: + +1. `True` - module setup succeeded +2. `None` - module setup soft-failed (scan will continue but module will be disabled) +3. `False` - module setup hard-failed (scan will abort) + +Optionally, it can also return a reason. Here are some examples: + +```python +async def setup(self): + if not self.config.get("api_key"): + # soft-fail + return None, "No API key specified" + +async def setup(self): + try: + wordlist = self.helpers.wordlist("https://raw.githubusercontent.com/user/wordlist.txt") + except WordlistError as e: + # hard-fail + return False, f"Error downloading wordlist: {e}" + +async def setup(self): + self.timeout = self.config.get("timeout", 5) + # success + return True +``` + +## Module Config Options + +Each module can have its own set of config options. These live in the `options` and `options_desc` attributes on your class. Both are dictionaries; `options` is for defaults and `options_desc` is for descriptions. Here is a typical example: + +```python title="bbot/modules/nmap.py" +class nmap(BaseModule): + # ... + options = { + "top_ports": 100, + "ports": "", + "timing": "T4", + "skip_host_discovery": True, + } + options_desc = { + "top_ports": "Top ports to scan (default 100) (to override, specify 'ports')", + "ports": "Ports to scan", + "timing": "-T<0-5>: Set timing template (higher is faster)", + "skip_host_discovery": "skip host discovery (-Pn)", + } + + async def setup(self): + self.ports = self.config.get("ports", "") + self.timing = self.config.get("timing", "T4") + self.top_ports = self.config.get("top_ports", 100) + self.skip_host_discovery = self.config.get("skip_host_discovery", True) + return True +``` + +Once you've defined these variables, you can pass the options via `-c`: + +```bash +bbot -m nmap -c modules.nmap.top_ports=250 +``` + +... or via the config: + +```yaml title="~/.config/bbot/bbot.yml" +modules: + nmap: + top_ports: 250 +``` + +Inside the module, you access them via `self.config`, e.g.: + +```python +self.config.get("top_ports") +``` + +## Module Dependencies + +BBOT automates module dependencies with **Ansible**. If your module relies on a third-party binary, OS package, or python library, you can specify them in the `deps_*` attributes of your module. + +```python +class MyModule(BaseModule): + ... + deps_apt = ["chromium-browser"] + deps_ansible = [ + { + "name": "install dev tools", + "package": {"name": ["gcc", "git", "make"], "state": "present"}, + "become": True, + "ignore_errors": True, + }, + { + "name": "Download massdns source code", + "git": { + "repo": "https://github.com/blechschmidt/massdns.git", + "dest": "#{BBOT_TEMP}/massdns", + "single_branch": True, + "version": "master", + }, + }, + { + "name": "Build massdns", + "command": {"chdir": "#{BBOT_TEMP}/massdns", "cmd": "make", "creates": "#{BBOT_TEMP}/massdns/bin/massdns"}, + }, + { + "name": "Install massdns", + "copy": {"src": "#{BBOT_TEMP}/massdns/bin/massdns", "dest": "#{BBOT_TOOLS}/", "mode": "u+x,g+x,o+x"}, + }, + ] +``` diff --git a/docs/dev/presets.md b/docs/dev/presets.md new file mode 100644 index 000000000..7bc7343e0 --- /dev/null +++ b/docs/dev/presets.md @@ -0,0 +1 @@ +::: bbot.scanner.Preset diff --git a/docs/dev/tests.md b/docs/dev/tests.md new file mode 100644 index 000000000..ebe9313b4 --- /dev/null +++ b/docs/dev/tests.md @@ -0,0 +1,85 @@ +# Unit Tests + +BBOT takes tests seriously. Every module *must* have a custom-written test that *actually tests* its functionality. Don't worry if you want to contribute but you aren't used to writing tests. If you open a draft PR, we will help write them :) + +We use [black](https://github.com/psf/black) and [flake8](https://flake8.pycqa.org/en/latest/) for linting, and [pytest](https://docs.pytest.org/en/8.2.x/) for tests. + +## Running tests locally + +We have Github actions that automatically run tests whenever you open a Pull Request. However, you can also run the tests locally with `pytest`: + +```bash +# format code with black +poetry run black . + +# lint with flake8 +poetry run flake8 + +# run all tests with pytest (takes rougly 30 minutes) +poetry run pytest +``` + +### Running specific tests + +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 +``` + +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" +``` + +If you want to see the output of your module, you can enable `--log-cli-level`: +```bash +poetry run pytest --log-cli-level=DEBUG +``` + +## Example: Writing a Module Test + +To write a test for your module, create a new python file in `bbot/test/test_step_2/module_tests`. Your filename must be `test_module_`: + +```python title="test_module_mymodule.py" +from .base import ModuleTestBase + + +class TestMyModule(ModuleTestBase): + targets = ["blacklanternsecurity.com"] + config_overrides = {"modules": {"mymodule": {"api_key": "deadbeef"}}} + + async def setup_after_prep(self, module_test): + # mock HTTP response + module_test.httpx_mock.add_response( + url="https://api.com/sudomains?apikey=deadbeef&domain=blacklanternsecurity.com", + json={ + "subdomains": [ + "www.blacklanternsecurity.com", + "dev.blacklanternsecurity.com" + ], + }, + ) + # mock DNS + await module_test.mock_dns( + { + "blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + "www.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + "dev.blacklanternsecurity.com": {"A": ["1.2.3.4"]}, + } + ) + + def check(self, module_test, events): + # here is where we check to make sure it worked + dns_names = [e.data for e in events if e.type == "DNS_NAME"] + assert "www.blacklanternsecurity.com" in dns_names, "failed to find subdomain #1" + assert "dev.blacklanternsecurity.com" in dns_names, "failed to find subdomain #2" +``` + +### More advanced tests + +If you have questions about tests or need to write a more advanced test, come talk to us on [GitHub](https://github.com/blacklanternsecurity/bbot/discussions) or [Discord](https://discord.com/invite/PZqkgxu5SA). + +It's also a good idea to look through our [existing tests](https://github.com/blacklanternsecurity/bbot/tree/stable/bbot/test/test_step_2/module_tests). BBOT has over a hundred of them, so you might find one that's similar to what you're trying to do. diff --git a/docs/diagrams/event-flow.drawio b/docs/diagrams/event-flow.drawio new file mode 100644 index 000000000..e90f1f051 --- /dev/null +++ b/docs/diagrams/event-flow.drawio @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/diagrams/event-flow.png b/docs/diagrams/event-flow.png new file mode 100644 index 000000000..8d36fd13a Binary files /dev/null and b/docs/diagrams/event-flow.png differ diff --git a/docs/diagrams/module-recursion.drawio b/docs/diagrams/module-recursion.drawio new file mode 100644 index 000000000..9d7e92a00 --- /dev/null +++ b/docs/diagrams/module-recursion.drawio @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/diagrams/module-recursion.png b/docs/diagrams/module-recursion.png new file mode 100644 index 000000000..d276ad722 Binary files /dev/null and b/docs/diagrams/module-recursion.png differ diff --git a/docs/how_it_works.md b/docs/how_it_works.md index d16bb50cf..21476818e 100644 --- a/docs/how_it_works.md +++ b/docs/how_it_works.md @@ -1,45 +1,37 @@ -# What is BBOT? +# How it Works -BBOT is a system of individual modules that interchange data **recursively**. Every module (e.g. `nmap`) _consumes_ a type of data (e.g. a `DNS_NAME`) and _emits_ another kind, (an `OPEN_TCP_PORT`). These bits of data, called [events](scanning/events.md), become the output of the tool, but are also redistributed to all the other modules, prompting them to dig deeper, and feeding the recursive cycle of discovery. +## BBOT's Recursive Philosophy -![recursion](https://github.com/blacklanternsecurity/bbot/assets/20261699/7b2edfca-2692-463b-939b-ab9d52d2fe00) +It is well-known that if you're doing recon, it's best to do it recursively. However, there are very few recursive tools out there, mainly because making a recursive tool (and keeping it stable) is pretty hard. BBOT manages this with extensive [Unit Tests](./dev/tests.md). -## What It **_Isn't_** +BBOT inherits its recursive philosophy from [Spiderfoot](https://github.com/smicallef/spiderfoot), which means it is also ***event-driven***. Each of BBOT's 100+ modules ***consume*** a certain type of [Event](./scanning/events.md), use it to discover something new, and ***produce*** new events, which get distributed to all the other modules. This happens again and again -- thousands of times during a scan -- spidering outwards in a recursive web of discovery. -It's important to understand that BBOT has a fundamentally different philosophy from most tools. Its discovery process does not have "phases", or "stages"; i.e. it does not work like this: +Below is an interactive graph showing the relationships between modules and the event types they produce and consume. -![how_it_doesnt_work](https://github.com/blacklanternsecurity/bbot/assets/20261699/67c4e332-f181-47e7-b884-2112bda347a4) + +
+ + -This is a traditional OSINT process, where you start with a target and you work in stages. Each stage gets you a little more data and requires more cleaning/deduplication, until finally you reach the end. The problem with this approach is that it **misses things**. +## How BBOT Modules Work Together -Imagine if on the last step of this process, you discovered a new subdomain. Awesome! But shouldn't you go back and check that one the same way you did the others? Shouldn't you port-scan it and SSL-mine it, extract its web contents, and so on? Let's assume you do that, and maybe during that process you even discover another subdomain! What about this time? Should you start over again for that one? You see the dilemma. +Each BBOT module does one specific task, such as querying an API for subdomains, or running a tool like `nuclei`, and is carefully designed to work together with other modules inside BBOT's recursive system. -![traditional-workflow](https://github.com/blacklanternsecurity/bbot/assets/20261699/aa7cb6ac-6f88-464a-8069-0d534cecfd2b) +For example, the `portscan` module consumes `DNS_NAME`, and produces `OPEN_TCP_PORT`. The `sslcert` module consumes `OPEN_TCP_PORT` and produces `DNS_NAME`. You can see how even these two modules, when enabled together, will feed each other recursively. -## Recursion +![module-recursion](https://github.com/blacklanternsecurity/bbot/assets/20261699/10ff5fb4-b3e7-453d-9772-7a26808b071e) -Recursion is at the heart of BBOT's design. Each newly-discovered piece of data is fed it back into the machine, fueling the discovery process. This continues until there is no new data to discover. +Because of this, enabling even one module has the potential to increase your results exponentially. This is exactly how BBOT is able to outperform other tools. -![bbot-workflow](https://github.com/blacklanternsecurity/bbot/assets/20261699/1b56c472-c2c4-41b5-b711-4b7296ec7b20) - -## Module Example - -In a simple example, we run a BBOT scan with **three modules**: `nmap`, `sslcert`, and `httpx`. Each of these modules "consume" a certain type of data: - -- **`nmap`** consumes `DNS_NAME`s, port-scans them, and outputs `OPEN_TCP_PORT`s -- **`sslcert`** consumes `OPEN_TCP_PORT`s, grabs certs, and extracts `DNS_NAME`s -- **`httpx`** consumes `OPEN_TCP_PORT`s and visits any web services, ultimately producing new `DNS_NAME`s - -```mermaid -graph TD - nmap -->|OPEN_TCP_PORT| sslcert; - nmap -->|OPEN_TCP_PORT| httpx; - sslcert --> |DNS_NAME| nmap; - httpx --> |DNS_NAME| nmap; -``` - -This allows for some interesting chains of events. Given a single target such as `evilcorp.com`, `nmap` may start by discovering an `OPEN_TCP_PORT` `evilcorp.com:443`. `sslcert` and `httpx` will then visit that port and extract more hostnames, which are in turn scanned by `nmap` to produce more open ports which are visited by `sslcert` and `httpx`, which discover more hostnames, which are again passed to `nmap`, and so on... - -This is a simple example with only a few modules, but you can being to see how if 30 or 40 modules were enabled, they could feed each other exponentially to produce an immense amount of data. This recursion is exactly how BBOT is able to outperform other tools. - -For a full list of event types and which modules consume/produce them, see [List of Event Types](scanning/events.md#list-of-event-types). +To learn more about how events flow inside BBOT, see [BBOT Internal Architecture](./dev/architecture.md). diff --git a/docs/index.md b/docs/index.md index ae590beef..3d6c5ef26 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Getting Started - + _A BBOT scan in real-time - visualization with [VivaGraphJS](https://github.com/blacklanternsecurity/bbot-vivagraphjs)_ @@ -10,7 +10,7 @@ _A BBOT scan in real-time - visualization with [VivaGraphJS](https://github.com/ Only **Linux** is supported at this time. **Windows** and **macOS** are *not* supported. If you use one of these platforms, consider using [Docker](#Docker). -BBOT offers multiple methods of installation, including **pipx** and **Docker**. If you plan to dev on BBOT, see [Installation (Poetry)](https://www.blacklanternsecurity.com/bbot/contribution/#installation-poetry). +BBOT offers multiple methods of installation, including **pipx** and **Docker**. If you plan to dev on BBOT, see [Installation (Poetry)](./contribution/#installation-poetry). ### [Python (pip / pipx)](https://pypi.org/project/bbot/) @@ -55,50 +55,69 @@ Below are some examples of common scans. ```bash # Perform a full subdomain enumeration on evilcorp.com -bbot -t evilcorp.com -f subdomain-enum +bbot -t evilcorp.com -p subdomain-enum ``` **Subdomains (passive only):** ```bash # Perform a passive-only subdomain enumeration on evilcorp.com -bbot -t evilcorp.com -f subdomain-enum -rf passive +bbot -t evilcorp.com -p subdomain-enum -rf passive ``` **Subdomains + port scan + web screenshots:** ```bash # Port-scan every subdomain, screenshot every webpage, output to current directory -bbot -t evilcorp.com -f subdomain-enum -m nmap gowitness -n my_scan -o . +bbot -t evilcorp.com -p subdomain-enum -m portscan gowitness -n my_scan -o . ``` **Subdomains + basic web scan:** ```bash # A basic web scan includes wappalyzer, robots.txt, and other non-intrusive web modules -bbot -t evilcorp.com -f subdomain-enum web-basic +bbot -t evilcorp.com -p subdomain-enum web-basic ``` **Web spider:** ```bash # Crawl www.evilcorp.com up to a max depth of 2, automatically extracting emails, secrets, etc. -bbot -t www.evilcorp.com -m httpx robots badsecrets secretsdb -c web_spider_distance=2 web_spider_depth=2 +bbot -t www.evilcorp.com -p spider -c web.spider_distance=2 web.spider_depth=2 ``` **Everything everywhere all at once:** ```bash # Subdomains, emails, cloud buckets, port scan, basic web, web screenshots, nuclei -bbot -t evilcorp.com -f subdomain-enum email-enum cloud-enum web-basic -m nmap gowitness nuclei --allow-deadly +bbot -t evilcorp.com -p kitchen-sink ``` ## API Keys -No API keys are required to run BBOT. However, some modules need them to function. If you have API keys and want to make use of these modules, you can place them either in BBOT's YAML config (`~/.config/bbot/secrets.yml`): +BBOT works just fine without API keys. However, there are certain modules that need them to function. If you have API keys and want to make use of these modules, you can place them either in your preset: -```yaml title="~/.config/bbot/secrets.yml" +```yaml title="my_preset.yml" +description: My custom subdomain enum preset + +include: + - subdomain-enum + - cloud-enum + +config: + modules: + shodan_dns: + api_key: deadbeef + virustotal: + api_key: cafebabe +``` + +...in BBOT's global YAML config (`~/.config/bbot/bbot.yml`): + +Note: this will ensure the API keys are used in all scans, regardless of preset. + +```yaml title="~/.config/bbot/bbot.yml" modules: shodan_dns: api_key: deadbeef @@ -106,7 +125,7 @@ modules: api_key: cafebabe ``` -Or on the command-line: +...or directly on the command-line: ```bash # specify API key with -c diff --git a/docs/javascripts/vega-embed@6.js b/docs/javascripts/vega-embed@6.js new file mode 100644 index 000000000..f6a451cee --- /dev/null +++ b/docs/javascripts/vega-embed@6.js @@ -0,0 +1,7 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("vega"),require("vega-lite")):"function"==typeof define&&define.amd?define(["vega","vega-lite"],t):(e="undefined"!=typeof globalThis?globalThis:e||self).vegaEmbed=t(e.vega,e.vegaLite)}(this,(function(e,t){"use strict";function n(e){var t=Object.create(null);return e&&Object.keys(e).forEach((function(n){if("default"!==n){var r=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(t,n,r.get?r:{enumerable:!0,get:function(){return e[n]}})}})),t.default=e,Object.freeze(t)}var r,i=n(e),o=n(t),a=(r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])},r(e,t)},function(e,t){function n(){this.constructor=e}r(e,t),e.prototype=null===t?Object.create(t):(n.prototype=t.prototype,new n)}),s=Object.prototype.hasOwnProperty;function l(e,t){return s.call(e,t)}function c(e){if(Array.isArray(e)){for(var t=new Array(e.length),n=0;n=48&&t<=57))return!1;n++}return!0}function p(e){return-1===e.indexOf("/")&&-1===e.indexOf("~")?e:e.replace(/~/g,"~0").replace(/\//g,"~1")}function d(e){return e.replace(/~1/g,"/").replace(/~0/g,"~")}function u(e){if(void 0===e)return!0;if(e)if(Array.isArray(e)){for(var t=0,n=e.length;t0&&"constructor"==s[c-1]))throw new TypeError("JSON-Patch: modifying `__proto__` or `constructor/prototype` prop is banned for security reasons, if this was on purpose, please set `banPrototypeModifications` flag false and pass it to this function. More info in fast-json-patch README");if(n&&void 0===u&&(void 0===l[g]?u=s.slice(0,c).join("/"):c==p-1&&(u=t.path),void 0!==u&&m(t,0,e,u)),c++,Array.isArray(l)){if("-"===g)g=l.length;else{if(n&&!f(g))throw new v("Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index","OPERATION_PATH_ILLEGAL_ARRAY_INDEX",o,t,e);f(g)&&(g=~~g)}if(c>=p){if(n&&"add"===t.op&&g>l.length)throw new v("The specified index MUST NOT be greater than the number of elements in the array","OPERATION_VALUE_OUT_OF_BOUNDS",o,t,e);if(!1===(a=y[t.op].call(t,l,g,e)).test)throw new v("Test operation failed","TEST_OPERATION_FAILED",o,t,e);return a}}else if(c>=p){if(!1===(a=b[t.op].call(t,l,g,e)).test)throw new v("Test operation failed","TEST_OPERATION_FAILED",o,t,e);return a}if(l=l[g],n&&c0)throw new v('Operation `path` property must start with "/"',"OPERATION_PATH_INVALID",t,e,n);if(("move"===e.op||"copy"===e.op)&&"string"!=typeof e.from)throw new v("Operation `from` property is not present (applicable in `move` and `copy` operations)","OPERATION_FROM_REQUIRED",t,e,n);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&void 0===e.value)throw new v("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_REQUIRED",t,e,n);if(("add"===e.op||"replace"===e.op||"test"===e.op)&&u(e.value))throw new v("Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)","OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED",t,e,n);if(n)if("add"==e.op){var i=e.path.split("/").length,o=r.split("/").length;if(i!==o+1&&i!==o)throw new v("Cannot perform an `add` operation at the desired path","OPERATION_PATH_CANNOT_ADD",t,e,n)}else if("replace"===e.op||"remove"===e.op||"_get"===e.op){if(e.path!==r)throw new v("Cannot perform the operation at a path that does not exist","OPERATION_PATH_UNRESOLVABLE",t,e,n)}else if("move"===e.op||"copy"===e.op){var a=I([{op:"_get",path:e.from,value:void 0}],n);if(a&&"OPERATION_PATH_UNRESOLVABLE"===a.name)throw new v("Cannot perform the operation from a path that does not exist","OPERATION_FROM_UNRESOLVABLE",t,e,n)}}function I(e,t,n){try{if(!Array.isArray(e))throw new v("Patch sequence must be an array","SEQUENCE_NOT_AN_ARRAY");if(t)O(h(t),h(e),n||!0);else{n=n||x;for(var r=0;r0&&(e.patches=[],e.callback&&e.callback(r)),r}function C(e,t,n,r,i){if(t!==e){"function"==typeof t.toJSON&&(t=t.toJSON());for(var o=c(t),a=c(e),s=!1,f=a.length-1;f>=0;f--){var d=e[g=a[f]];if(!l(t,g)||void 0===t[g]&&void 0!==d&&!1===Array.isArray(t))Array.isArray(e)===Array.isArray(t)?(i&&n.push({op:"test",path:r+"/"+p(g),value:h(d)}),n.push({op:"remove",path:r+"/"+p(g)}),s=!0):(i&&n.push({op:"test",path:r,value:e}),n.push({op:"replace",path:r,value:t}));else{var u=t[g];"object"==typeof d&&null!=d&&"object"==typeof u&&null!=u&&Array.isArray(d)===Array.isArray(u)?C(d,u,n,r+"/"+p(g),i):d!==u&&(i&&n.push({op:"test",path:r+"/"+p(g),value:h(d)}),n.push({op:"replace",path:r+"/"+p(g),value:h(u)}))}}if(s||o.length!=a.length)for(f=0;f0)return[m,n+c.join(",\n"+u),s].join("\n"+o)}return v}(e,"",0)},j=F(M);var z=U;function U(e){var t=this;if(t instanceof U||(t=new U),t.tail=null,t.head=null,t.length=0,e&&"function"==typeof e.forEach)e.forEach((function(e){t.push(e)}));else if(arguments.length>0)for(var n=0,r=arguments.length;n1)n=t;else{if(!this.head)throw new TypeError("Reduce of empty list with no initial value");r=this.head.next,n=this.head.value}for(var i=0;null!==r;i++)n=e(n,r.value,i),r=r.next;return n},U.prototype.reduceReverse=function(e,t){var n,r=this.tail;if(arguments.length>1)n=t;else{if(!this.tail)throw new TypeError("Reduce of empty list with no initial value");r=this.tail.prev,n=this.tail.value}for(var i=this.length-1;null!==r;i--)n=e(n,r.value,i),r=r.prev;return n},U.prototype.toArray=function(){for(var e=new Array(this.length),t=0,n=this.head;null!==n;t++)e[t]=n.value,n=n.next;return e},U.prototype.toArrayReverse=function(){for(var e=new Array(this.length),t=0,n=this.tail;null!==n;t++)e[t]=n.value,n=n.prev;return e},U.prototype.slice=function(e,t){(t=t||this.length)<0&&(t+=this.length),(e=e||0)<0&&(e+=this.length);var n=new U;if(tthis.length&&(t=this.length);for(var r=0,i=this.head;null!==i&&rthis.length&&(t=this.length);for(var r=this.length,i=this.tail;null!==i&&r>t;r--)i=i.prev;for(;null!==i&&r>e;r--,i=i.prev)n.push(i.value);return n},U.prototype.splice=function(e,t,...n){e>this.length&&(e=this.length-1),e<0&&(e=this.length+e);for(var r=0,i=this.head;null!==i&&r1;const ie=(e,t,n)=>{const r=e[te].get(t);if(r){const t=r.value;if(oe(e,t)){if(se(e,r),!e[J])return}else n&&(e[ne]&&(r.value.now=Date.now()),e[ee].unshiftNode(r));return t.value}},oe=(e,t)=>{if(!t||!t.maxAge&&!e[Q])return!1;const n=Date.now()-t.now;return t.maxAge?n>t.maxAge:e[Q]&&n>e[Q]},ae=e=>{if(e[q]>e[H])for(let t=e[ee].tail;e[q]>e[H]&&null!==t;){const n=t.prev;se(e,t),t=n}},se=(e,t)=>{if(t){const n=t.value;e[Z]&&e[Z](n.key,n.value),e[q]-=n.length,e[te].delete(n.key),e[ee].removeNode(t)}};class le{constructor(e,t,n,r,i){this.key=e,this.value=t,this.length=n,this.now=r,this.maxAge=i||0}}const ce=(e,t,n,r)=>{let i=n.value;oe(e,i)&&(se(e,n),e[J]||(i=void 0)),i&&t.call(r,i.value,i.key,e)};var he=class{constructor(e){if("number"==typeof e&&(e={max:e}),e||(e={}),e.max&&("number"!=typeof e.max||e.max<0))throw new TypeError("max must be a non-negative number");this[H]=e.max||1/0;const t=e.length||re;if(this[Y]="function"!=typeof t?re:t,this[J]=e.stale||!1,e.maxAge&&"number"!=typeof e.maxAge)throw new TypeError("maxAge must be a number");this[Q]=e.maxAge||0,this[Z]=e.dispose,this[K]=e.noDisposeOnSet||!1,this[ne]=e.updateAgeOnGet||!1,this.reset()}set max(e){if("number"!=typeof e||e<0)throw new TypeError("max must be a non-negative number");this[H]=e||1/0,ae(this)}get max(){return this[H]}set allowStale(e){this[J]=!!e}get allowStale(){return this[J]}set maxAge(e){if("number"!=typeof e)throw new TypeError("maxAge must be a non-negative number");this[Q]=e,ae(this)}get maxAge(){return this[Q]}set lengthCalculator(e){"function"!=typeof e&&(e=re),e!==this[Y]&&(this[Y]=e,this[q]=0,this[ee].forEach((e=>{e.length=this[Y](e.value,e.key),this[q]+=e.length}))),ae(this)}get lengthCalculator(){return this[Y]}get length(){return this[q]}get itemCount(){return this[ee].length}rforEach(e,t){t=t||this;for(let n=this[ee].tail;null!==n;){const r=n.prev;ce(this,e,n,t),n=r}}forEach(e,t){t=t||this;for(let n=this[ee].head;null!==n;){const r=n.next;ce(this,e,n,t),n=r}}keys(){return this[ee].toArray().map((e=>e.key))}values(){return this[ee].toArray().map((e=>e.value))}reset(){this[Z]&&this[ee]&&this[ee].length&&this[ee].forEach((e=>this[Z](e.key,e.value))),this[te]=new Map,this[ee]=new V,this[q]=0}dump(){return this[ee].map((e=>!oe(this,e)&&{k:e.key,v:e.value,e:e.now+(e.maxAge||0)})).toArray().filter((e=>e))}dumpLru(){return this[ee]}set(e,t,n){if((n=n||this[Q])&&"number"!=typeof n)throw new TypeError("maxAge must be a number");const r=n?Date.now():0,i=this[Y](t,e);if(this[te].has(e)){if(i>this[H])return se(this,this[te].get(e)),!1;const o=this[te].get(e).value;return this[Z]&&(this[K]||this[Z](e,o.value)),o.now=r,o.maxAge=n,o.value=t,this[q]+=i-o.length,o.length=i,this.get(e),ae(this),!0}const o=new le(e,t,i,r,n);return o.length>this[H]?(this[Z]&&this[Z](e,t),!1):(this[q]+=o.length,this[ee].unshift(o),this[te].set(e,this[ee].head),ae(this),!0)}has(e){if(!this[te].has(e))return!1;const t=this[te].get(e).value;return!oe(this,t)}get(e){return ie(this,e,!0)}peek(e){return ie(this,e,!1)}pop(){const e=this[ee].tail;return e?(se(this,e),e.value):null}del(e){se(this,this[te].get(e))}load(e){this.reset();const t=Date.now();for(let n=e.length-1;n>=0;n--){const r=e[n],i=r.e||0;if(0===i)this.set(r.k,r.v);else{const e=i-t;e>0&&this.set(r.k,r.v,e)}}}prune(){this[te].forEach(((e,t)=>ie(this,t,!1)))}};const fe=Object.freeze({loose:!0}),pe=Object.freeze({});var de=e=>e?"object"!=typeof e?fe:e:pe,ue={exports:{}};var ge={MAX_LENGTH:256,MAX_SAFE_COMPONENT_LENGTH:16,MAX_SAFE_BUILD_LENGTH:250,MAX_SAFE_INTEGER:Number.MAX_SAFE_INTEGER||9007199254740991,RELEASE_TYPES:["major","premajor","minor","preminor","patch","prepatch","prerelease"],SEMVER_SPEC_VERSION:"2.0.0",FLAG_INCLUDE_PRERELEASE:1,FLAG_LOOSE:2};var me="object"==typeof process&&process.env&&process.env.NODE_DEBUG&&/\bsemver\b/i.test(process.env.NODE_DEBUG)?(...e)=>console.error("SEMVER",...e):()=>{};!function(e,t){const{MAX_SAFE_COMPONENT_LENGTH:n,MAX_SAFE_BUILD_LENGTH:r,MAX_LENGTH:i}=ge,o=me,a=(t=e.exports={}).re=[],s=t.safeRe=[],l=t.src=[],c=t.t={};let h=0;const f="[a-zA-Z0-9-]",p=[["\\s",1],["\\d",i],[f,r]],d=(e,t,n)=>{const r=(e=>{for(const[t,n]of p)e=e.split(`${t}*`).join(`${t}{0,${n}}`).split(`${t}+`).join(`${t}{1,${n}}`);return e})(t),i=h++;o(e,i,t),c[e]=i,l[i]=t,a[i]=new RegExp(t,n?"g":void 0),s[i]=new RegExp(r,n?"g":void 0)};d("NUMERICIDENTIFIER","0|[1-9]\\d*"),d("NUMERICIDENTIFIERLOOSE","\\d+"),d("NONNUMERICIDENTIFIER",`\\d*[a-zA-Z-]${f}*`),d("MAINVERSION",`(${l[c.NUMERICIDENTIFIER]})\\.(${l[c.NUMERICIDENTIFIER]})\\.(${l[c.NUMERICIDENTIFIER]})`),d("MAINVERSIONLOOSE",`(${l[c.NUMERICIDENTIFIERLOOSE]})\\.(${l[c.NUMERICIDENTIFIERLOOSE]})\\.(${l[c.NUMERICIDENTIFIERLOOSE]})`),d("PRERELEASEIDENTIFIER",`(?:${l[c.NUMERICIDENTIFIER]}|${l[c.NONNUMERICIDENTIFIER]})`),d("PRERELEASEIDENTIFIERLOOSE",`(?:${l[c.NUMERICIDENTIFIERLOOSE]}|${l[c.NONNUMERICIDENTIFIER]})`),d("PRERELEASE",`(?:-(${l[c.PRERELEASEIDENTIFIER]}(?:\\.${l[c.PRERELEASEIDENTIFIER]})*))`),d("PRERELEASELOOSE",`(?:-?(${l[c.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${l[c.PRERELEASEIDENTIFIERLOOSE]})*))`),d("BUILDIDENTIFIER",`${f}+`),d("BUILD",`(?:\\+(${l[c.BUILDIDENTIFIER]}(?:\\.${l[c.BUILDIDENTIFIER]})*))`),d("FULLPLAIN",`v?${l[c.MAINVERSION]}${l[c.PRERELEASE]}?${l[c.BUILD]}?`),d("FULL",`^${l[c.FULLPLAIN]}$`),d("LOOSEPLAIN",`[v=\\s]*${l[c.MAINVERSIONLOOSE]}${l[c.PRERELEASELOOSE]}?${l[c.BUILD]}?`),d("LOOSE",`^${l[c.LOOSEPLAIN]}$`),d("GTLT","((?:<|>)?=?)"),d("XRANGEIDENTIFIERLOOSE",`${l[c.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`),d("XRANGEIDENTIFIER",`${l[c.NUMERICIDENTIFIER]}|x|X|\\*`),d("XRANGEPLAIN",`[v=\\s]*(${l[c.XRANGEIDENTIFIER]})(?:\\.(${l[c.XRANGEIDENTIFIER]})(?:\\.(${l[c.XRANGEIDENTIFIER]})(?:${l[c.PRERELEASE]})?${l[c.BUILD]}?)?)?`),d("XRANGEPLAINLOOSE",`[v=\\s]*(${l[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${l[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${l[c.XRANGEIDENTIFIERLOOSE]})(?:${l[c.PRERELEASELOOSE]})?${l[c.BUILD]}?)?)?`),d("XRANGE",`^${l[c.GTLT]}\\s*${l[c.XRANGEPLAIN]}$`),d("XRANGELOOSE",`^${l[c.GTLT]}\\s*${l[c.XRANGEPLAINLOOSE]}$`),d("COERCEPLAIN",`(^|[^\\d])(\\d{1,${n}})(?:\\.(\\d{1,${n}}))?(?:\\.(\\d{1,${n}}))?`),d("COERCE",`${l[c.COERCEPLAIN]}(?:$|[^\\d])`),d("COERCEFULL",l[c.COERCEPLAIN]+`(?:${l[c.PRERELEASE]})?`+`(?:${l[c.BUILD]})?(?:$|[^\\d])`),d("COERCERTL",l[c.COERCE],!0),d("COERCERTLFULL",l[c.COERCEFULL],!0),d("LONETILDE","(?:~>?)"),d("TILDETRIM",`(\\s*)${l[c.LONETILDE]}\\s+`,!0),t.tildeTrimReplace="$1~",d("TILDE",`^${l[c.LONETILDE]}${l[c.XRANGEPLAIN]}$`),d("TILDELOOSE",`^${l[c.LONETILDE]}${l[c.XRANGEPLAINLOOSE]}$`),d("LONECARET","(?:\\^)"),d("CARETTRIM",`(\\s*)${l[c.LONECARET]}\\s+`,!0),t.caretTrimReplace="$1^",d("CARET",`^${l[c.LONECARET]}${l[c.XRANGEPLAIN]}$`),d("CARETLOOSE",`^${l[c.LONECARET]}${l[c.XRANGEPLAINLOOSE]}$`),d("COMPARATORLOOSE",`^${l[c.GTLT]}\\s*(${l[c.LOOSEPLAIN]})$|^$`),d("COMPARATOR",`^${l[c.GTLT]}\\s*(${l[c.FULLPLAIN]})$|^$`),d("COMPARATORTRIM",`(\\s*)${l[c.GTLT]}\\s*(${l[c.LOOSEPLAIN]}|${l[c.XRANGEPLAIN]})`,!0),t.comparatorTrimReplace="$1$2$3",d("HYPHENRANGE",`^\\s*(${l[c.XRANGEPLAIN]})\\s+-\\s+(${l[c.XRANGEPLAIN]})\\s*$`),d("HYPHENRANGELOOSE",`^\\s*(${l[c.XRANGEPLAINLOOSE]})\\s+-\\s+(${l[c.XRANGEPLAINLOOSE]})\\s*$`),d("STAR","(<|>)?=?\\s*\\*"),d("GTE0","^\\s*>=\\s*0\\.0\\.0\\s*$"),d("GTE0PRE","^\\s*>=\\s*0\\.0\\.0-0\\s*$")}(ue,ue.exports);var ve=ue.exports;const Ee=/^[0-9]+$/,be=(e,t)=>{const n=Ee.test(e),r=Ee.test(t);return n&&r&&(e=+e,t=+t),e===t?0:n&&!r?-1:r&&!n?1:ebe(t,e)};const we=me,{MAX_LENGTH:Ae,MAX_SAFE_INTEGER:Oe}=ge,{safeRe:xe,t:Ie}=ve,Ne=de,{compareIdentifiers:Le}=ye;var Re=class e{constructor(t,n){if(n=Ne(n),t instanceof e){if(t.loose===!!n.loose&&t.includePrerelease===!!n.includePrerelease)return t;t=t.version}else if("string"!=typeof t)throw new TypeError(`Invalid version. Must be a string. Got type "${typeof t}".`);if(t.length>Ae)throw new TypeError(`version is longer than ${Ae} characters`);we("SemVer",t,n),this.options=n,this.loose=!!n.loose,this.includePrerelease=!!n.includePrerelease;const r=t.trim().match(n.loose?xe[Ie.LOOSE]:xe[Ie.FULL]);if(!r)throw new TypeError(`Invalid Version: ${t}`);if(this.raw=t,this.major=+r[1],this.minor=+r[2],this.patch=+r[3],this.major>Oe||this.major<0)throw new TypeError("Invalid major version");if(this.minor>Oe||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>Oe||this.patch<0)throw new TypeError("Invalid patch version");r[4]?this.prerelease=r[4].split(".").map((e=>{if(/^[0-9]+$/.test(e)){const t=+e;if(t>=0&&t=0;)"number"==typeof this.prerelease[r]&&(this.prerelease[r]++,r=-2);if(-1===r){if(t===this.prerelease.join(".")&&!1===n)throw new Error("invalid increment argument: identifier already exists");this.prerelease.push(e)}}if(t){let r=[t,e];!1===n&&(r=[t]),0===Le(this.prerelease[0],t)?isNaN(this.prerelease[1])&&(this.prerelease=r):this.prerelease=r}break}default:throw new Error(`invalid increment argument: ${e}`)}return this.raw=this.format(),this.build.length&&(this.raw+=`+${this.build.join(".")}`),this}};const $e=Re;var Se=(e,t,n)=>new $e(e,n).compare(new $e(t,n));const Te=Se;const Ce=Se;const De=Se;const Fe=Se;const ke=Se;const _e=Se;const Pe=(e,t,n)=>0===Te(e,t,n),Me=(e,t,n)=>0!==Ce(e,t,n),je=(e,t,n)=>De(e,t,n)>0,ze=(e,t,n)=>Fe(e,t,n)>=0,Ue=(e,t,n)=>ke(e,t,n)<0,Be=(e,t,n)=>_e(e,t,n)<=0;var Ge,We,Xe,Ve,He=(e,t,n,r)=>{switch(t){case"===":return"object"==typeof e&&(e=e.version),"object"==typeof n&&(n=n.version),e===n;case"!==":return"object"==typeof e&&(e=e.version),"object"==typeof n&&(n=n.version),e!==n;case"":case"=":case"==":return Pe(e,n,r);case"!=":return Me(e,n,r);case">":return je(e,n,r);case">=":return ze(e,n,r);case"<":return Ue(e,n,r);case"<=":return Be(e,n,r);default:throw new TypeError(`Invalid operator: ${t}`)}};function qe(){if(Ve)return Xe;Ve=1;class e{constructor(t,i){if(i=n(i),t instanceof e)return t.loose===!!i.loose&&t.includePrerelease===!!i.includePrerelease?t:new e(t.raw,i);if(t instanceof r)return this.raw=t.value,this.set=[[t]],this.format(),this;if(this.options=i,this.loose=!!i.loose,this.includePrerelease=!!i.includePrerelease,this.raw=t.trim().split(/\s+/).join(" "),this.set=this.raw.split("||").map((e=>this.parseRange(e.trim()))).filter((e=>e.length)),!this.set.length)throw new TypeError(`Invalid SemVer Range: ${this.raw}`);if(this.set.length>1){const e=this.set[0];if(this.set=this.set.filter((e=>!d(e[0]))),0===this.set.length)this.set=[e];else if(this.set.length>1)for(const e of this.set)if(1===e.length&&u(e[0])){this.set=[e];break}}this.format()}format(){return this.range=this.set.map((e=>e.join(" ").trim())).join("||").trim(),this.range}toString(){return this.range}parseRange(e){const n=((this.options.includePrerelease&&f)|(this.options.loose&&p))+":"+e,o=t.get(n);if(o)return o;const u=this.options.loose,g=u?a[s.HYPHENRANGELOOSE]:a[s.HYPHENRANGE];e=e.replace(g,N(this.options.includePrerelease)),i("hyphen replace",e),e=e.replace(a[s.COMPARATORTRIM],l),i("comparator trim",e),e=e.replace(a[s.TILDETRIM],c),i("tilde trim",e),e=e.replace(a[s.CARETTRIM],h),i("caret trim",e);let v=e.split(" ").map((e=>m(e,this.options))).join(" ").split(/\s+/).map((e=>I(e,this.options)));u&&(v=v.filter((e=>(i("loose invalid filter",e,this.options),!!e.match(a[s.COMPARATORLOOSE]))))),i("range list",v);const E=new Map,b=v.map((e=>new r(e,this.options)));for(const e of b){if(d(e))return[e];E.set(e.value,e)}E.size>1&&E.has("")&&E.delete("");const y=[...E.values()];return t.set(n,y),y}intersects(t,n){if(!(t instanceof e))throw new TypeError("a Range is required");return this.set.some((e=>g(e,n)&&t.set.some((t=>g(t,n)&&e.every((e=>t.every((t=>e.intersects(t,n)))))))))}test(e){if(!e)return!1;if("string"==typeof e)try{e=new o(e,this.options)}catch(e){return!1}for(let t=0;t")||!e.operator.startsWith(">"))&&(!this.operator.startsWith("<")||!e.operator.startsWith("<"))&&(this.semver.version!==e.semver.version||!this.operator.includes("=")||!e.operator.includes("="))&&!(o(this.semver,"<",e.semver,r)&&this.operator.startsWith(">")&&e.operator.startsWith("<"))&&!(o(this.semver,">",e.semver,r)&&this.operator.startsWith("<")&&e.operator.startsWith(">")))}}Ge=t;const n=de,{safeRe:r,t:i}=ve,o=He,a=me,s=Re,l=qe();return Ge}(),i=me,o=Re,{safeRe:a,t:s,comparatorTrimReplace:l,tildeTrimReplace:c,caretTrimReplace:h}=ve,{FLAG_INCLUDE_PRERELEASE:f,FLAG_LOOSE:p}=ge,d=e=>"<0.0.0-0"===e.value,u=e=>""===e.value,g=(e,t)=>{let n=!0;const r=e.slice();let i=r.pop();for(;n&&r.length;)n=r.every((e=>i.intersects(e,t))),i=r.pop();return n},m=(e,t)=>(i("comp",e,t),e=y(e,t),i("caret",e),e=E(e,t),i("tildes",e),e=A(e,t),i("xrange",e),e=x(e,t),i("stars",e),e),v=e=>!e||"x"===e.toLowerCase()||"*"===e,E=(e,t)=>e.trim().split(/\s+/).map((e=>b(e,t))).join(" "),b=(e,t)=>{const n=t.loose?a[s.TILDELOOSE]:a[s.TILDE];return e.replace(n,((t,n,r,o,a)=>{let s;return i("tilde",e,t,n,r,o,a),v(n)?s="":v(r)?s=`>=${n}.0.0 <${+n+1}.0.0-0`:v(o)?s=`>=${n}.${r}.0 <${n}.${+r+1}.0-0`:a?(i("replaceTilde pr",a),s=`>=${n}.${r}.${o}-${a} <${n}.${+r+1}.0-0`):s=`>=${n}.${r}.${o} <${n}.${+r+1}.0-0`,i("tilde return",s),s}))},y=(e,t)=>e.trim().split(/\s+/).map((e=>w(e,t))).join(" "),w=(e,t)=>{i("caret",e,t);const n=t.loose?a[s.CARETLOOSE]:a[s.CARET],r=t.includePrerelease?"-0":"";return e.replace(n,((t,n,o,a,s)=>{let l;return i("caret",e,t,n,o,a,s),v(n)?l="":v(o)?l=`>=${n}.0.0${r} <${+n+1}.0.0-0`:v(a)?l="0"===n?`>=${n}.${o}.0${r} <${n}.${+o+1}.0-0`:`>=${n}.${o}.0${r} <${+n+1}.0.0-0`:s?(i("replaceCaret pr",s),l="0"===n?"0"===o?`>=${n}.${o}.${a}-${s} <${n}.${o}.${+a+1}-0`:`>=${n}.${o}.${a}-${s} <${n}.${+o+1}.0-0`:`>=${n}.${o}.${a}-${s} <${+n+1}.0.0-0`):(i("no pr"),l="0"===n?"0"===o?`>=${n}.${o}.${a}${r} <${n}.${o}.${+a+1}-0`:`>=${n}.${o}.${a}${r} <${n}.${+o+1}.0-0`:`>=${n}.${o}.${a} <${+n+1}.0.0-0`),i("caret return",l),l}))},A=(e,t)=>(i("replaceXRanges",e,t),e.split(/\s+/).map((e=>O(e,t))).join(" ")),O=(e,t)=>{e=e.trim();const n=t.loose?a[s.XRANGELOOSE]:a[s.XRANGE];return e.replace(n,((n,r,o,a,s,l)=>{i("xRange",e,n,r,o,a,s,l);const c=v(o),h=c||v(a),f=h||v(s),p=f;return"="===r&&p&&(r=""),l=t.includePrerelease?"-0":"",c?n=">"===r||"<"===r?"<0.0.0-0":"*":r&&p?(h&&(a=0),s=0,">"===r?(r=">=",h?(o=+o+1,a=0,s=0):(a=+a+1,s=0)):"<="===r&&(r="<",h?o=+o+1:a=+a+1),"<"===r&&(l="-0"),n=`${r+o}.${a}.${s}${l}`):h?n=`>=${o}.0.0${l} <${+o+1}.0.0-0`:f&&(n=`>=${o}.${a}.0${l} <${o}.${+a+1}.0-0`),i("xRange return",n),n}))},x=(e,t)=>(i("replaceStars",e,t),e.trim().replace(a[s.STAR],"")),I=(e,t)=>(i("replaceGTE0",e,t),e.trim().replace(a[t.includePrerelease?s.GTE0PRE:s.GTE0],"")),N=e=>(t,n,r,i,o,a,s,l,c,h,f,p,d)=>`${n=v(r)?"":v(i)?`>=${r}.0.0${e?"-0":""}`:v(o)?`>=${r}.${i}.0${e?"-0":""}`:a?`>=${n}`:`>=${n}${e?"-0":""}`} ${l=v(c)?"":v(h)?`<${+c+1}.0.0-0`:v(f)?`<${c}.${+h+1}.0-0`:p?`<=${c}.${h}.${f}-${p}`:e?`<${c}.${h}.${+f+1}-0`:`<=${l}`}`.trim(),L=(e,t,n)=>{for(let n=0;n0){const r=e[n].semver;if(r.major===t.major&&r.minor===t.minor&&r.patch===t.patch)return!0}return!1}return!0};return Xe}const Ye=qe();var Je=(e,t,n)=>{try{t=new Ye(t,n)}catch(e){return!1}return t.test(e)},Qe=F(Je);var Ze={NaN:NaN,E:Math.E,LN2:Math.LN2,LN10:Math.LN10,LOG2E:Math.LOG2E,LOG10E:Math.LOG10E,PI:Math.PI,SQRT1_2:Math.SQRT1_2,SQRT2:Math.SQRT2,MIN_VALUE:Number.MIN_VALUE,MAX_VALUE:Number.MAX_VALUE},Ke={"*":(e,t)=>e*t,"+":(e,t)=>e+t,"-":(e,t)=>e-t,"/":(e,t)=>e/t,"%":(e,t)=>e%t,">":(e,t)=>e>t,"<":(e,t)=>ee<=t,">=":(e,t)=>e>=t,"==":(e,t)=>e==t,"!=":(e,t)=>e!=t,"===":(e,t)=>e===t,"!==":(e,t)=>e!==t,"&":(e,t)=>e&t,"|":(e,t)=>e|t,"^":(e,t)=>e^t,"<<":(e,t)=>e<>":(e,t)=>e>>t,">>>":(e,t)=>e>>>t},et={"+":e=>+e,"-":e=>-e,"~":e=>~e,"!":e=>!e};const tt=Array.prototype.slice,nt=(e,t,n)=>{const r=n?n(t[0]):t[0];return r[e].apply(r,tt.call(t,1))};var rt={isNaN:Number.isNaN,isFinite:Number.isFinite,abs:Math.abs,acos:Math.acos,asin:Math.asin,atan:Math.atan,atan2:Math.atan2,ceil:Math.ceil,cos:Math.cos,exp:Math.exp,floor:Math.floor,log:Math.log,max:Math.max,min:Math.min,pow:Math.pow,random:Math.random,round:Math.round,sin:Math.sin,sqrt:Math.sqrt,tan:Math.tan,clamp:(e,t,n)=>Math.max(t,Math.min(n,e)),now:Date.now,utc:Date.UTC,datetime:(e,t,n,r,i,o,a)=>new Date(e,t||0,null!=n?n:1,r||0,i||0,o||0,a||0),date:e=>new Date(e).getDate(),day:e=>new Date(e).getDay(),year:e=>new Date(e).getFullYear(),month:e=>new Date(e).getMonth(),hours:e=>new Date(e).getHours(),minutes:e=>new Date(e).getMinutes(),seconds:e=>new Date(e).getSeconds(),milliseconds:e=>new Date(e).getMilliseconds(),time:e=>new Date(e).getTime(),timezoneoffset:e=>new Date(e).getTimezoneOffset(),utcdate:e=>new Date(e).getUTCDate(),utcday:e=>new Date(e).getUTCDay(),utcyear:e=>new Date(e).getUTCFullYear(),utcmonth:e=>new Date(e).getUTCMonth(),utchours:e=>new Date(e).getUTCHours(),utcminutes:e=>new Date(e).getUTCMinutes(),utcseconds:e=>new Date(e).getUTCSeconds(),utcmilliseconds:e=>new Date(e).getUTCMilliseconds(),length:e=>e.length,join:function(){return nt("join",arguments)},indexof:function(){return nt("indexOf",arguments)},lastindexof:function(){return nt("lastIndexOf",arguments)},slice:function(){return nt("slice",arguments)},reverse:e=>e.slice().reverse(),parseFloat:parseFloat,parseInt:parseInt,upper:e=>String(e).toUpperCase(),lower:e=>String(e).toLowerCase(),substring:function(){return nt("substring",arguments,String)},split:function(){return nt("split",arguments,String)},replace:function(){return nt("replace",arguments,String)},trim:e=>String(e).trim(),regexp:RegExp,test:(e,t)=>RegExp(e).test(t)};const it=["view","item","group","xy","x","y"],ot=new Set([Function,eval,setTimeout,setInterval]);"function"==typeof setImmediate&&ot.add(setImmediate);const at={Literal:(e,t)=>t.value,Identifier:(e,t)=>{const n=t.name;return e.memberDepth>0?n:"datum"===n?e.datum:"event"===n?e.event:"item"===n?e.item:Ze[n]||e.params["$"+n]},MemberExpression:(e,t)=>{const n=!t.computed,r=e(t.object);n&&(e.memberDepth+=1);const i=e(t.property);if(n&&(e.memberDepth-=1),!ot.has(r[i]))return r[i];console.error(`Prevented interpretation of member "${i}" which could lead to insecure code execution`)},CallExpression:(e,t)=>{const n=t.arguments;let r=t.callee.name;return r.startsWith("_")&&(r=r.slice(1)),"if"===r?e(n[0])?e(n[1]):e(n[2]):(e.fn[r]||rt[r]).apply(e.fn,n.map(e))},ArrayExpression:(e,t)=>t.elements.map(e),BinaryExpression:(e,t)=>Ke[t.operator](e(t.left),e(t.right)),UnaryExpression:(e,t)=>et[t.operator](e(t.argument)),ConditionalExpression:(e,t)=>e(t.test)?e(t.consequent):e(t.alternate),LogicalExpression:(e,t)=>"&&"===t.operator?e(t.left)&&e(t.right):e(t.left)||e(t.right),ObjectExpression:(e,t)=>t.properties.reduce(((t,n)=>{e.memberDepth+=1;const r=e(n.key);return e.memberDepth-=1,ot.has(e(n.value))?console.error(`Prevented interpretation of property "${r}" which could lead to insecure code execution`):t[r]=e(n.value),t}),{})};function st(e,t,n,r,i,o){const a=e=>at[e.type](a,e);return a.memberDepth=0,a.fn=Object.create(t),a.params=n,a.datum=r,a.event=i,a.item=o,it.forEach((e=>a.fn[e]=function(){return i.vega[e](...arguments)})),a(e)}var lt={operator(e,t){const n=t.ast,r=e.functions;return e=>st(n,r,e)},parameter(e,t){const n=t.ast,r=e.functions;return(e,t)=>st(n,r,t,e)},event(e,t){const n=t.ast,r=e.functions;return e=>st(n,r,void 0,void 0,e)},handler(e,t){const n=t.ast,r=e.functions;return(e,t)=>{const i=t.item&&t.item.datum;return st(n,r,e,i,t)}},encode(e,t){const{marktype:n,channels:r}=t,i=e.functions,o="group"===n||"image"===n||"rect"===n;return(e,t)=>{const a=e.datum;let s,l=0;for(const n in r)s=st(r[n].ast,i,t,a,void 0,e),e[n]!==s&&(e[n]=s,l=1);return"rule"!==n&&function(e,t,n){let r;t.x2&&(t.x?(n&&e.x>e.x2&&(r=e.x,e.x=e.x2,e.x2=r),e.width=e.x2-e.x):e.x=e.x2-(e.width||0)),t.xc&&(e.x=e.xc-(e.width||0)/2),t.y2&&(t.y?(n&&e.y>e.y2&&(r=e.y,e.y=e.y2,e.y2=r),e.height=e.y2-e.y):e.y=e.y2-(e.height||0)),t.yc&&(e.y=e.yc-(e.height||0)/2)}(e,r,o),l}}};function ct(e){const[t,n]=/schema\/([\w-]+)\/([\w\.\-]+)\.json$/g.exec(e).slice(1,3);return{library:t,version:n}}var ht="2.14.0";const ft="#fff",pt="#888",dt={background:"#333",view:{stroke:pt},title:{color:ft,subtitleColor:ft},style:{"guide-label":{fill:ft},"guide-title":{fill:ft}},axis:{domainColor:ft,gridColor:pt,tickColor:ft}},ut="#4572a7",gt={background:"#fff",arc:{fill:ut},area:{fill:ut},line:{stroke:ut,strokeWidth:2},path:{stroke:ut},rect:{fill:ut},shape:{stroke:ut},symbol:{fill:ut,strokeWidth:1.5,size:50},axis:{bandPosition:.5,grid:!0,gridColor:"#000000",gridOpacity:1,gridWidth:.5,labelPadding:10,tickSize:5,tickWidth:.5},axisBand:{grid:!1,tickExtra:!0},legend:{labelBaseline:"middle",labelFontSize:11,symbolSize:50,symbolType:"square"},range:{category:["#4572a7","#aa4643","#8aa453","#71598e","#4598ae","#d98445","#94aace","#d09393","#b9cc98","#a99cbc"]}},mt="#30a2da",vt="#cbcbcb",Et="#f0f0f0",bt="#333",yt={arc:{fill:mt},area:{fill:mt},axis:{domainColor:vt,grid:!0,gridColor:vt,gridWidth:1,labelColor:"#999",labelFontSize:10,titleColor:"#333",tickColor:vt,tickSize:10,titleFontSize:14,titlePadding:10,labelPadding:4},axisBand:{grid:!1},background:Et,group:{fill:Et},legend:{labelColor:bt,labelFontSize:11,padding:1,symbolSize:30,symbolType:"square",titleColor:bt,titleFontSize:14,titlePadding:10},line:{stroke:mt,strokeWidth:2},path:{stroke:mt,strokeWidth:.5},rect:{fill:mt},range:{category:["#30a2da","#fc4f30","#e5ae38","#6d904f","#8b8b8b","#b96db8","#ff9e27","#56cc60","#52d2ca","#52689e","#545454","#9fe4f8"],diverging:["#cc0020","#e77866","#f6e7e1","#d6e8ed","#91bfd9","#1d78b5"],heatmap:["#d6e8ed","#cee0e5","#91bfd9","#549cc6","#1d78b5"]},point:{filled:!0,shape:"circle"},shape:{stroke:mt},bar:{binSpacing:2,fill:mt,stroke:null},title:{anchor:"start",fontSize:24,fontWeight:600,offset:20}},wt="#000",At={group:{fill:"#e5e5e5"},arc:{fill:wt},area:{fill:wt},line:{stroke:wt},path:{stroke:wt},rect:{fill:wt},shape:{stroke:wt},symbol:{fill:wt,size:40},axis:{domain:!1,grid:!0,gridColor:"#FFFFFF",gridOpacity:1,labelColor:"#7F7F7F",labelPadding:4,tickColor:"#7F7F7F",tickSize:5.67,titleFontSize:16,titleFontWeight:"normal"},legend:{labelBaseline:"middle",labelFontSize:11,symbolSize:40},range:{category:["#000000","#7F7F7F","#1A1A1A","#999999","#333333","#B0B0B0","#4D4D4D","#C9C9C9","#666666","#DCDCDC"]}},Ot="Benton Gothic, sans-serif",xt="#82c6df",It="Benton Gothic Bold, sans-serif",Nt="normal",Lt={"category-6":["#ec8431","#829eb1","#c89d29","#3580b1","#adc839","#ab7fb4"],"fire-7":["#fbf2c7","#f9e39c","#f8d36e","#f4bb6a","#e68a4f","#d15a40","#ab4232"],"fireandice-6":["#e68a4f","#f4bb6a","#f9e39c","#dadfe2","#a6b7c6","#849eae"],"ice-7":["#edefee","#dadfe2","#c4ccd2","#a6b7c6","#849eae","#607785","#47525d"]},Rt={background:"#ffffff",title:{anchor:"start",color:"#000000",font:It,fontSize:22,fontWeight:"normal"},arc:{fill:xt},area:{fill:xt},line:{stroke:xt,strokeWidth:2},path:{stroke:xt},rect:{fill:xt},shape:{stroke:xt},symbol:{fill:xt,size:30},axis:{labelFont:Ot,labelFontSize:11.5,labelFontWeight:"normal",titleFont:It,titleFontSize:13,titleFontWeight:Nt},axisX:{labelAngle:0,labelPadding:4,tickSize:3},axisY:{labelBaseline:"middle",maxExtent:45,minExtent:45,tickSize:2,titleAlign:"left",titleAngle:0,titleX:-45,titleY:-11},legend:{labelFont:Ot,labelFontSize:11.5,symbolType:"square",titleFont:It,titleFontSize:13,titleFontWeight:Nt},range:{category:Lt["category-6"],diverging:Lt["fireandice-6"],heatmap:Lt["fire-7"],ordinal:Lt["fire-7"],ramp:Lt["fire-7"]}},$t="#ab5787",St="#979797",Tt={background:"#f9f9f9",arc:{fill:$t},area:{fill:$t},line:{stroke:$t},path:{stroke:$t},rect:{fill:$t},shape:{stroke:$t},symbol:{fill:$t,size:30},axis:{domainColor:St,domainWidth:.5,gridWidth:.2,labelColor:St,tickColor:St,tickWidth:.2,titleColor:St},axisBand:{grid:!1},axisX:{grid:!0,tickSize:10},axisY:{domain:!1,grid:!0,tickSize:0},legend:{labelFontSize:11,padding:1,symbolSize:30,symbolType:"square"},range:{category:["#ab5787","#51b2e5","#703c5c","#168dd9","#d190b6","#00609f","#d365ba","#154866","#666666","#c4c4c4"]}},Ct="#3e5c69",Dt={background:"#fff",arc:{fill:Ct},area:{fill:Ct},line:{stroke:Ct},path:{stroke:Ct},rect:{fill:Ct},shape:{stroke:Ct},symbol:{fill:Ct},axis:{domainWidth:.5,grid:!0,labelPadding:2,tickSize:5,tickWidth:.5,titleFontWeight:"normal"},axisBand:{grid:!1},axisX:{gridWidth:.2},axisY:{gridDash:[3],gridWidth:.4},legend:{labelFontSize:11,padding:1,symbolType:"square"},range:{category:["#3e5c69","#6793a6","#182429","#0570b0","#3690c0","#74a9cf","#a6bddb","#e2ddf2"]}},Ft="#1696d2",kt="#000000",_t="Lato",Pt="Lato",Mt={"main-colors":["#1696d2","#d2d2d2","#000000","#fdbf11","#ec008b","#55b748","#5c5859","#db2b27"],"shades-blue":["#CFE8F3","#A2D4EC","#73BFE2","#46ABDB","#1696D2","#12719E","#0A4C6A","#062635"],"shades-gray":["#F5F5F5","#ECECEC","#E3E3E3","#DCDBDB","#D2D2D2","#9D9D9D","#696969","#353535"],"shades-yellow":["#FFF2CF","#FCE39E","#FDD870","#FCCB41","#FDBF11","#E88E2D","#CA5800","#843215"],"shades-magenta":["#F5CBDF","#EB99C2","#E46AA7","#E54096","#EC008B","#AF1F6B","#761548","#351123"],"shades-green":["#DCEDD9","#BCDEB4","#98CF90","#78C26D","#55B748","#408941","#2C5C2D","#1A2E19"],"shades-black":["#D5D5D4","#ADABAC","#848081","#5C5859","#332D2F","#262223","#1A1717","#0E0C0D"],"shades-red":["#F8D5D4","#F1AAA9","#E9807D","#E25552","#DB2B27","#A4201D","#6E1614","#370B0A"],"one-group":["#1696d2","#000000"],"two-groups-cat-1":["#1696d2","#000000"],"two-groups-cat-2":["#1696d2","#fdbf11"],"two-groups-cat-3":["#1696d2","#db2b27"],"two-groups-seq":["#a2d4ec","#1696d2"],"three-groups-cat":["#1696d2","#fdbf11","#000000"],"three-groups-seq":["#a2d4ec","#1696d2","#0a4c6a"],"four-groups-cat-1":["#000000","#d2d2d2","#fdbf11","#1696d2"],"four-groups-cat-2":["#1696d2","#ec0008b","#fdbf11","#5c5859"],"four-groups-seq":["#cfe8f3","#73bf42","#1696d2","#0a4c6a"],"five-groups-cat-1":["#1696d2","#fdbf11","#d2d2d2","#ec008b","#000000"],"five-groups-cat-2":["#1696d2","#0a4c6a","#d2d2d2","#fdbf11","#332d2f"],"five-groups-seq":["#cfe8f3","#73bf42","#1696d2","#0a4c6a","#000000"],"six-groups-cat-1":["#1696d2","#ec008b","#fdbf11","#000000","#d2d2d2","#55b748"],"six-groups-cat-2":["#1696d2","#d2d2d2","#ec008b","#fdbf11","#332d2f","#0a4c6a"],"six-groups-seq":["#cfe8f3","#a2d4ec","#73bfe2","#46abdb","#1696d2","#12719e"],"diverging-colors":["#ca5800","#fdbf11","#fdd870","#fff2cf","#cfe8f3","#73bfe2","#1696d2","#0a4c6a"]},jt={background:"#FFFFFF",title:{anchor:"start",fontSize:18,font:_t},axisX:{domain:!0,domainColor:kt,domainWidth:1,grid:!1,labelFontSize:12,labelFont:Pt,labelAngle:0,tickColor:kt,tickSize:5,titleFontSize:12,titlePadding:10,titleFont:_t},axisY:{domain:!1,domainWidth:1,grid:!0,gridColor:"#DEDDDD",gridWidth:1,labelFontSize:12,labelFont:Pt,labelPadding:8,ticks:!1,titleFontSize:12,titlePadding:10,titleFont:_t,titleAngle:0,titleY:-10,titleX:18},legend:{labelFontSize:12,labelFont:Pt,symbolSize:100,titleFontSize:12,titlePadding:10,titleFont:_t,orient:"right",offset:10},view:{stroke:"transparent"},range:{category:Mt["six-groups-cat-1"],diverging:Mt["diverging-colors"],heatmap:Mt["diverging-colors"],ordinal:Mt["six-groups-seq"],ramp:Mt["shades-blue"]},area:{fill:Ft},rect:{fill:Ft},line:{color:Ft,stroke:Ft,strokeWidth:5},trail:{color:Ft,stroke:Ft,strokeWidth:0,size:1},path:{stroke:Ft,strokeWidth:.5},point:{filled:!0},text:{font:"Lato",color:Ft,fontSize:11,align:"center",fontWeight:400,size:11},style:{bar:{fill:Ft,stroke:null}},arc:{fill:Ft},shape:{stroke:Ft},symbol:{fill:Ft,size:30}},zt="#3366CC",Ut="#ccc",Bt="Arial, sans-serif",Gt={arc:{fill:zt},area:{fill:zt},path:{stroke:zt},rect:{fill:zt},shape:{stroke:zt},symbol:{stroke:zt},circle:{fill:zt},background:"#fff",padding:{top:10,right:10,bottom:10,left:10},style:{"guide-label":{font:Bt,fontSize:12},"guide-title":{font:Bt,fontSize:12},"group-title":{font:Bt,fontSize:12}},title:{font:Bt,fontSize:14,fontWeight:"bold",dy:-3,anchor:"start"},axis:{gridColor:Ut,tickColor:Ut,domain:!1,grid:!0},range:{category:["#4285F4","#DB4437","#F4B400","#0F9D58","#AB47BC","#00ACC1","#FF7043","#9E9D24","#5C6BC0","#F06292","#00796B","#C2185B"],heatmap:["#c6dafc","#5e97f6","#2a56c6"]}},Wt=e=>e*(1/3+1),Xt=Wt(9),Vt=Wt(10),Ht=Wt(12),qt="Segoe UI",Yt="wf_standard-font, helvetica, arial, sans-serif",Jt="#252423",Qt="#605E5C",Zt="transparent",Kt="#118DFF",en="#DEEFFF",tn=[en,Kt],nn={view:{stroke:Zt},background:Zt,font:qt,header:{titleFont:Yt,titleFontSize:Ht,titleColor:Jt,labelFont:qt,labelFontSize:Vt,labelColor:Qt},axis:{ticks:!1,grid:!1,domain:!1,labelColor:Qt,labelFontSize:Xt,titleFont:Yt,titleColor:Jt,titleFontSize:Ht,titleFontWeight:"normal"},axisQuantitative:{tickCount:3,grid:!0,gridColor:"#C8C6C4",gridDash:[1,5],labelFlush:!1},axisBand:{tickExtra:!0},axisX:{labelPadding:5},axisY:{labelPadding:10},bar:{fill:Kt},line:{stroke:Kt,strokeWidth:3,strokeCap:"round",strokeJoin:"round"},text:{font:qt,fontSize:Xt,fill:Qt},arc:{fill:Kt},area:{fill:Kt,line:!0,opacity:.6},path:{stroke:Kt},rect:{fill:Kt},point:{fill:Kt,filled:!0,size:75},shape:{stroke:Kt},symbol:{fill:Kt,strokeWidth:1.5,size:50},legend:{titleFont:qt,titleFontWeight:"bold",titleColor:Qt,labelFont:qt,labelFontSize:Vt,labelColor:Qt,symbolType:"circle",symbolSize:75},range:{category:[Kt,"#12239E","#E66C37","#6B007B","#E044A7","#744EC2","#D9B300","#D64550"],diverging:tn,heatmap:tn,ordinal:[en,"#c7e4ff","#b0d9ff","#9aceff","#83c3ff","#6cb9ff","#55aeff","#3fa3ff","#2898ff",Kt]}},rn='IBM Plex Sans,system-ui,-apple-system,BlinkMacSystemFont,".sfnstext-regular",sans-serif',on=["#8a3ffc","#33b1ff","#007d79","#ff7eb6","#fa4d56","#fff1f1","#6fdc8c","#4589ff","#d12771","#d2a106","#08bdba","#bae6ff","#ba4e00","#d4bbff"],an=["#6929c4","#1192e8","#005d5d","#9f1853","#fa4d56","#570408","#198038","#002d9c","#ee538b","#b28600","#009d9a","#012749","#8a3800","#a56eff"];function sn({type:e,background:t}){const n="dark"===e?"#161616":"#ffffff",r="dark"===e?"#f4f4f4":"#161616",i="dark"===e?"#d4bbff":"#6929c4";return{background:t,arc:{fill:i},area:{fill:i},path:{stroke:i},rect:{fill:i},shape:{stroke:i},symbol:{stroke:i},circle:{fill:i},view:{fill:n,stroke:n},group:{fill:n},title:{color:r,anchor:"start",dy:-15,fontSize:16,font:rn,fontWeight:600},axis:{labelColor:r,labelFontSize:12,grid:!0,gridColor:"#525252",titleColor:r,labelAngle:0},style:{"guide-label":{font:rn,fill:r,fontWeight:400},"guide-title":{font:rn,fill:r,fontWeight:400}},range:{category:"dark"===e?on:an,diverging:["#750e13","#a2191f","#da1e28","#fa4d56","#ff8389","#ffb3b8","#ffd7d9","#fff1f1","#e5f6ff","#bae6ff","#82cfff","#33b1ff","#1192e8","#0072c3","#00539a","#003a6d"],heatmap:["#f6f2ff","#e8daff","#d4bbff","#be95ff","#a56eff","#8a3ffc","#6929c4","#491d8b","#31135e","#1c0f30"]}}}const ln=sn({type:"light",background:"#ffffff"}),cn=sn({type:"light",background:"#f4f4f4"}),hn=sn({type:"dark",background:"#262626"}),fn=sn({type:"dark",background:"#161616"}),pn=ht;var dn=Object.freeze({__proto__:null,carbong10:cn,carbong100:fn,carbong90:hn,carbonwhite:ln,dark:dt,excel:gt,fivethirtyeight:yt,ggplot2:At,googlecharts:Gt,latimes:Rt,powerbi:nn,quartz:Tt,urbaninstitute:jt,version:pn,vox:Dt});function un(e,t,n){return e.fields=t||[],e.fname=n,e}function gn(e){return 1===e.length?mn(e[0]):vn(e)}const mn=e=>function(t){return t[e]},vn=e=>{const t=e.length;return function(n){for(let r=0;rr&&c(),s=r=i+1):"]"===o&&(s||En("Access path missing open bracket: "+e),s>0&&c(),s=0,r=i+1):i>r?c():r=i+1}return s&&En("Access path missing closing bracket: "+e),a&&En("Access path missing closing quote: "+e),i>r&&(i++,c()),t}(e);e=1===r.length?r[0]:e,un((n&&n.get||gn)(r),[e],t||e)}("id"),un((e=>e),[],"identity"),un((()=>0),[],"zero"),un((()=>1),[],"one"),un((()=>!0),[],"true"),un((()=>!1),[],"false");var bn=Array.isArray;function yn(e){return e===Object(e)}function wn(e,t){return JSON.stringify(e,function(e){const t=[];return function(n,r){if("object"!=typeof r||null===r)return r;const i=t.indexOf(this)+1;return t.length=i,t.length>e?"[Object]":t.indexOf(r)>=0?"[Circular]":(t.push(r),r)}}(t))}var An="#vg-tooltip-element {\n visibility: hidden;\n padding: 8px;\n position: fixed;\n z-index: 1000;\n font-family: sans-serif;\n font-size: 11px;\n border-radius: 3px;\n box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);\n /* The default theme is the light theme. */\n background-color: rgba(255, 255, 255, 0.95);\n border: 1px solid #d9d9d9;\n color: black;\n}\n#vg-tooltip-element.visible {\n visibility: visible;\n}\n#vg-tooltip-element h2 {\n margin-top: 0;\n margin-bottom: 10px;\n font-size: 13px;\n}\n#vg-tooltip-element table {\n border-spacing: 0;\n}\n#vg-tooltip-element table tr {\n border: none;\n}\n#vg-tooltip-element table tr td {\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n padding-bottom: 2px;\n}\n#vg-tooltip-element table tr td.key {\n color: #808080;\n max-width: 150px;\n text-align: right;\n padding-right: 4px;\n}\n#vg-tooltip-element table tr td.value {\n display: block;\n max-width: 300px;\n max-height: 7em;\n text-align: left;\n}\n#vg-tooltip-element.dark-theme {\n background-color: rgba(32, 32, 32, 0.9);\n border: 1px solid #f5f5f5;\n color: white;\n}\n#vg-tooltip-element.dark-theme td.key {\n color: #bfbfbf;\n}\n";const On="vg-tooltip-element",xn={offsetX:10,offsetY:10,id:On,styleId:"vega-tooltip-style",theme:"light",disableDefaultStyle:!1,sanitize:function(e){return String(e).replace(/&/g,"&").replace(/t("string"==typeof e?e:wn(e,n)))).join(", ")}]`;if(yn(e)){let i="";const{title:o,image:a,...s}=e;o&&(i+=`

${t(o)}

`),a&&(i+=``);const l=Object.keys(s);if(l.length>0){i+="";for(const e of l){let r=s[e];void 0!==r&&(yn(r)&&(r=wn(r,n)),i+=``)}i+="
${t(e)}${t(r)}
"}return i||"{}"}return t(e)},baseURL:""};class In{constructor(e){this.options={...xn,...e};const t=this.options.id;if(this.el=null,this.call=this.tooltipHandler.bind(this),!this.options.disableDefaultStyle&&!document.getElementById(this.options.styleId)){const e=document.createElement("style");e.setAttribute("id",this.options.styleId),e.innerHTML=function(e){if(!/^[A-Za-z]+[-:.\w]*$/.test(e))throw new Error("Invalid HTML ID");return An.toString().replace(On,e)}(t);const n=document.head;n.childNodes.length>0?n.insertBefore(e,n.childNodes[0]):n.appendChild(e)}}tooltipHandler(e,t,n,r){if(this.el=document.getElementById(this.options.id),!this.el){this.el=document.createElement("div"),this.el.setAttribute("id",this.options.id),this.el.classList.add("vg-tooltip");(document.fullscreenElement??document.body).appendChild(this.el)}if(null==r||""===r)return void this.el.classList.remove("visible",`${this.options.theme}-theme`);this.el.innerHTML=this.options.formatTooltip(r,this.options.sanitize,this.options.maxDepth,this.options.baseURL),this.el.classList.add("visible",`${this.options.theme}-theme`);const{x:i,y:o}=function(e,t,n,r){let i=e.clientX+n;i+t.width>window.innerWidth&&(i=+e.clientX-n-t.width);let o=e.clientY+r;return o+t.height>window.innerHeight&&(o=+e.clientY-r-t.height),{x:i,y:o}}(t,this.el.getBoundingClientRect(),this.options.offsetX,this.options.offsetY);this.el.style.top=`${o}px`,this.el.style.left=`${i}px`}}var Nn='.vega-embed {\n position: relative;\n display: inline-block;\n box-sizing: border-box;\n}\n.vega-embed.has-actions {\n padding-right: 38px;\n}\n.vega-embed details:not([open]) > :not(summary) {\n display: none !important;\n}\n.vega-embed summary {\n list-style: none;\n position: absolute;\n top: 0;\n right: 0;\n padding: 6px;\n z-index: 1000;\n background: white;\n box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1);\n color: #1b1e23;\n border: 1px solid #aaa;\n border-radius: 999px;\n opacity: 0.2;\n transition: opacity 0.4s ease-in;\n cursor: pointer;\n line-height: 0px;\n}\n.vega-embed summary::-webkit-details-marker {\n display: none;\n}\n.vega-embed summary:active {\n box-shadow: #aaa 0px 0px 0px 1px inset;\n}\n.vega-embed summary svg {\n width: 14px;\n height: 14px;\n}\n.vega-embed details[open] summary {\n opacity: 0.7;\n}\n.vega-embed:hover summary, .vega-embed:focus-within summary {\n opacity: 1 !important;\n transition: opacity 0.2s ease;\n}\n.vega-embed .vega-actions {\n position: absolute;\n z-index: 1001;\n top: 35px;\n right: -9px;\n display: flex;\n flex-direction: column;\n padding-bottom: 8px;\n padding-top: 8px;\n border-radius: 4px;\n box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.2);\n border: 1px solid #d9d9d9;\n background: white;\n animation-duration: 0.15s;\n animation-name: scale-in;\n animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1.5);\n text-align: left;\n}\n.vega-embed .vega-actions a {\n padding: 8px 16px;\n font-family: sans-serif;\n font-size: 14px;\n font-weight: 600;\n white-space: nowrap;\n color: #434a56;\n text-decoration: none;\n}\n.vega-embed .vega-actions a:hover, .vega-embed .vega-actions a:focus {\n background-color: #f7f7f9;\n color: black;\n}\n.vega-embed .vega-actions::before, .vega-embed .vega-actions::after {\n content: "";\n display: inline-block;\n position: absolute;\n}\n.vega-embed .vega-actions::before {\n left: auto;\n right: 14px;\n top: -16px;\n border: 8px solid rgba(0, 0, 0, 0);\n border-bottom-color: #d9d9d9;\n}\n.vega-embed .vega-actions::after {\n left: auto;\n right: 15px;\n top: -14px;\n border: 7px solid rgba(0, 0, 0, 0);\n border-bottom-color: #fff;\n}\n.vega-embed .chart-wrapper.fit-x {\n width: 100%;\n}\n.vega-embed .chart-wrapper.fit-y {\n height: 100%;\n}\n\n.vega-embed-wrapper {\n max-width: 100%;\n overflow: auto;\n padding-right: 14px;\n}\n\n@keyframes scale-in {\n from {\n opacity: 0;\n transform: scale(0.6);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}\n';function Ln(e,...t){for(const n of t)Rn(e,n);return e}function Rn(t,n){for(const r of Object.keys(n))e.writeConfig(t,r,n[r],!0)}const $n="6.25.0",Sn=i;let Tn=o;const Cn="undefined"!=typeof window?window:void 0;void 0===Tn&&Cn?.vl?.compile&&(Tn=Cn.vl);const Dn={export:{svg:!0,png:!0},source:!0,compiled:!0,editor:!0},Fn={CLICK_TO_VIEW_ACTIONS:"Click to view actions",COMPILED_ACTION:"View Compiled Vega",EDITOR_ACTION:"Open in Vega Editor",PNG_ACTION:"Save as PNG",SOURCE_ACTION:"View Source",SVG_ACTION:"Save as SVG"},kn={vega:"Vega","vega-lite":"Vega-Lite"},_n={vega:Sn.version,"vega-lite":Tn?Tn.version:"not available"},Pn={vega:e=>e,"vega-lite":(e,t)=>Tn.compile(e,{config:t}).spec},Mn='\n\n \n \n \n',jn="chart-wrapper";function zn(e,t,n,r){const i=`${t}
`,o=`
${n}`,a=window.open("");a.document.write(i+e+o),a.document.title=`${kn[r]} JSON Source`}function Un(e){return!(!e||!("load"in e))}function Bn(e){return Un(e)?e:Sn.loader(e)}async function Gn(t,n,r={}){let i,o;e.isString(n)?(o=Bn(r.loader),i=JSON.parse(await o.load(n))):i=n;const a=function(t){const n=t.usermeta?.embedOptions??{};return e.isString(n.defaultStyle)&&(n.defaultStyle=!1),n}(i),s=a.loader;o&&!s||(o=Bn(r.loader??s));const l=await Wn(a,o),c=await Wn(r,o),h={...Ln(c,l),config:e.mergeConfig(c.config??{},l.config??{})};return await async function(t,n,r={},i){const o=r.theme?e.mergeConfig(dn[r.theme],r.config??{}):r.config,a=e.isBoolean(r.actions)?r.actions:Ln({},Dn,r.actions??{}),s={...Fn,...r.i18n},l=r.renderer??"canvas",c=r.logLevel??Sn.Warn,h=r.downloadFileName??"visualization",f="string"==typeof t?document.querySelector(t):t;if(!f)throw new Error(`${t} does not exist`);if(!1!==r.defaultStyle){const e="vega-embed-style",{root:t,rootContainer:n}=function(e){const t=e.getRootNode?e.getRootNode():document;return t instanceof ShadowRoot?{root:t,rootContainer:t}:{root:document,rootContainer:document.head??document.body}}(f);if(!t.getElementById(e)){const t=document.createElement("style");t.id=e,t.innerHTML=void 0===r.defaultStyle||!0===r.defaultStyle?Nn.toString():r.defaultStyle,n.appendChild(t)}}const p=function(e,t){if(e.$schema){const n=ct(e.$schema);t&&t!==n.library&&console.warn(`The given visualization spec is written in ${kn[n.library]}, but mode argument sets ${kn[t]??t}.`);const r=n.library;return Qe(_n[r],`^${n.version.slice(1)}`)||console.warn(`The input spec uses ${kn[r]} ${n.version}, but the current version of ${kn[r]} is v${_n[r]}.`),r}return"mark"in e||"encoding"in e||"layer"in e||"hconcat"in e||"vconcat"in e||"facet"in e||"repeat"in e?"vega-lite":"marks"in e||"signals"in e||"scales"in e||"axes"in e?"vega":t??"vega"}(n,r.mode);let d=Pn[p](n,o);if("vega-lite"===p&&d.$schema){const e=ct(d.$schema);Qe(_n.vega,`^${e.version.slice(1)}`)||console.warn(`The compiled spec uses Vega ${e.version}, but current version is v${_n.vega}.`)}f.classList.add("vega-embed"),a&&f.classList.add("has-actions");f.innerHTML="";let u=f;if(a){const e=document.createElement("div");e.classList.add(jn),f.appendChild(e),u=e}const g=r.patch;g&&(d=g instanceof Function?g(d):O(d,g,!0,!1).newDocument);r.formatLocale&&Sn.formatLocale(r.formatLocale);r.timeFormatLocale&&Sn.timeFormatLocale(r.timeFormatLocale);if(r.expressionFunctions)for(const e in r.expressionFunctions){const t=r.expressionFunctions[e];"fn"in t?Sn.expressionFunction(e,t.fn,t.visitor):t instanceof Function&&Sn.expressionFunction(e,t)}const{ast:m}=r,v=Sn.parse(d,"vega-lite"===p?{}:o,{ast:m}),E=new(r.viewClass||Sn.View)(v,{loader:i,logLevel:c,renderer:l,...m?{expr:Sn.expressionInterpreter??r.expr??lt}:{}});if(E.addSignalListener("autosize",((e,t)=>{const{type:n}=t;"fit-x"==n?(u.classList.add("fit-x"),u.classList.remove("fit-y")):"fit-y"==n?(u.classList.remove("fit-x"),u.classList.add("fit-y")):"fit"==n?u.classList.add("fit-x","fit-y"):u.classList.remove("fit-x","fit-y")})),!1!==r.tooltip){const{loader:e,tooltip:t}=r,n=e&&!Un(e)?e?.baseURL:void 0,i="function"==typeof t?t:new In({baseURL:n,...!0===t?{}:t}).call;E.tooltip(i)}let b,{hover:y}=r;void 0===y&&(y="vega"===p);if(y){const{hoverSet:e,updateSet:t}="boolean"==typeof y?{}:y;E.hover(e,t)}r&&(null!=r.width&&E.width(r.width),null!=r.height&&E.height(r.height),null!=r.padding&&E.padding(r.padding));if(await E.initialize(u,r.bind).runAsync(),!1!==a){let t=f;if(!1!==r.defaultStyle||r.forceActionsMenu){const e=document.createElement("details");e.title=s.CLICK_TO_VIEW_ACTIONS,f.append(e),t=e;const n=document.createElement("summary");n.innerHTML=Mn,e.append(n),b=t=>{e.contains(t.target)||e.removeAttribute("open")},document.addEventListener("click",b)}const i=document.createElement("div");if(t.append(i),i.classList.add("vega-actions"),!0===a||!1!==a.export)for(const t of["svg","png"])if(!0===a||!0===a.export||a.export[t]){const n=s[`${t.toUpperCase()}_ACTION`],o=document.createElement("a"),a=e.isObject(r.scaleFactor)?r.scaleFactor[t]:r.scaleFactor;o.text=n,o.href="#",o.target="_blank",o.download=`${h}.${t}`,o.addEventListener("mousedown",(async function(e){e.preventDefault();const n=await E.toImageURL(t,a);this.href=n})),i.append(o)}if(!0===a||!1!==a.source){const e=document.createElement("a");e.text=s.SOURCE_ACTION,e.href="#",e.addEventListener("click",(function(e){zn(j(n),r.sourceHeader??"",r.sourceFooter??"",p),e.preventDefault()})),i.append(e)}if("vega-lite"===p&&(!0===a||!1!==a.compiled)){const e=document.createElement("a");e.text=s.COMPILED_ACTION,e.href="#",e.addEventListener("click",(function(e){zn(j(d),r.sourceHeader??"",r.sourceFooter??"","vega"),e.preventDefault()})),i.append(e)}if(!0===a||!1!==a.editor){const e=r.editorUrl??"https://vega.github.io/editor/",t=document.createElement("a");t.text=s.EDITOR_ACTION,t.href="#",t.addEventListener("click",(function(t){!function(e,t,n){const r=e.open(t),{origin:i}=new URL(t);let o=40;e.addEventListener("message",(function t(n){n.source===r&&(o=0,e.removeEventListener("message",t,!1))}),!1),setTimeout((function e(){o<=0||(r.postMessage(n,i),setTimeout(e,250),o-=1)}),250)}(window,e,{config:o,mode:p,renderer:l,spec:j(n)}),t.preventDefault()})),i.append(t)}}function w(){b&&document.removeEventListener("click",b),E.finalize()}return{view:E,spec:n,vgSpec:d,finalize:w,embedOptions:r}}(t,i,h,o)}async function Wn(t,n){const r=e.isString(t.config)?JSON.parse(await n.load(t.config)):t.config??{},i=e.isString(t.patch)?JSON.parse(await n.load(t.patch)):t.patch;return{...t,...i?{patch:i}:{},...r?{config:r}:{}}}async function Xn(e,t={}){const n=document.createElement("div");n.classList.add("vega-embed-wrapper");const r=document.createElement("div");n.appendChild(r);const i=!0===t.actions||!1===t.actions?t.actions:{export:!0,source:!1,compiled:!0,editor:!0,...t.actions},o=await Gn(r,e,{actions:i,...t});return n.value=o.view,n}const Vn=(...t)=>{return t.length>1&&(e.isString(t[0])&&!((n=t[0]).startsWith("http://")||n.startsWith("https://")||n.startsWith("//"))||t[0]instanceof HTMLElement||3===t.length)?Gn(t[0],t[1],t[2]):Xn(t[0],t[1]);var n};return Vn.vegaLite=Tn,Vn.vl=Tn,Vn.container=Xn,Vn.embed=Gn,Vn.vega=Sn,Vn.default=Gn,Vn.version=$n,Vn})); +//# sourceMappingURL=vega-embed.min.js.map diff --git a/docs/javascripts/vega-lite@5.js b/docs/javascripts/vega-lite@5.js new file mode 100644 index 000000000..f45748029 --- /dev/null +++ b/docs/javascripts/vega-lite@5.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("vega")):"function"==typeof define&&define.amd?define(["exports","vega"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).vegaLite={},e.vega)}(this,(function(e,t){"use strict";var n="5.18.1";function i(e){return!!e.or}function r(e){return!!e.and}function o(e){return!!e.not}function a(e,t){if(o(e))a(e.not,t);else if(r(e))for(const n of e.and)a(n,t);else if(i(e))for(const n of e.or)a(n,t);else t(e)}function s(e,t){return o(e)?{not:s(e.not,t)}:r(e)?{and:e.and.map((e=>s(e,t)))}:i(e)?{or:e.or.map((e=>s(e,t)))}:t(e)}const l=structuredClone;function c(e){throw new Error(e)}function u(e,n){const i={};for(const r of n)t.hasOwnProperty(e,r)&&(i[r]=e[r]);return i}function f(e,t){const n={...e};for(const e of t)delete n[e];return n}function d(e){if(t.isNumber(e))return e;const n=t.isString(e)?e:X(e);if(n.length<250)return n;let i=0;for(let e=0;e1?t-1:0),i=1;i0===t?e:`[${e}]`)),r=e.map(((t,n)=>e.slice(0,n+1).join("")));for(const e of r)n.add(e)}return n}function k(e,t){return void 0===e||void 0===t||$(w(e),w(t))}function S(e){return 0===D(e).length}Set.prototype.toJSON=function(){return`Set(${[...this].map((e=>X(e))).join(",")})`};const D=Object.keys,F=Object.values,z=Object.entries;function O(e){return!0===e||!1===e}function _(e){const t=e.replace(/\W/g,"_");return(e.match(/^\d+/)?"_":"")+t}function C(e,t){return o(e)?`!(${C(e.not,t)})`:r(e)?`(${e.and.map((e=>C(e,t))).join(") && (")})`:i(e)?`(${e.or.map((e=>C(e,t))).join(") || (")})`:t(e)}function N(e,t){if(0===t.length)return!0;const n=t.shift();return n in e&&N(e[n],t)&&delete e[n],S(e)}function P(e){return e.charAt(0).toUpperCase()+e.substr(1)}function A(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"datum";const i=t.splitAccessPath(e),r=[];for(let e=1;e<=i.length;e++){const o=`[${i.slice(0,e).map(t.stringValue).join("][")}]`;r.push(`${n}${o}`)}return r.join(" && ")}function j(e){return`${arguments.length>1&&void 0!==arguments[1]?arguments[1]:"datum"}[${t.stringValue(t.splitAccessPath(e).join("."))}]`}function T(e){return e.replace(/(\[|\]|\.|'|")/g,"\\$1")}function E(e){return`${t.splitAccessPath(e).map(T).join("\\.")}`}function M(e,t,n){return e.replace(new RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"g"),n)}function L(e){return`${t.splitAccessPath(e).join(".")}`}function q(e){return e?t.splitAccessPath(e).length:0}function U(){for(var e=arguments.length,t=new Array(e),n=0;nfn(e[t])?_(`_${t}_${z(e[t])}`):_(`_${t}_${e[t]}`))).join("")}function ln(e){return!0===e||un(e)&&!e.binned}function cn(e){return"binned"===e||un(e)&&!0===e.binned}function un(e){return t.isObject(e)}function fn(e){return e?.param}function dn(e){switch(e){case Q:case J:case ye:case me:case pe:case ge:case we:case be:case xe:case $e:case he:return 6;case ke:return 4;default:return 10}}function mn(e){return!!e?.expr}function pn(e){const t=D(e||{}),n={};for(const i of t)n[i]=Sn(e[i]);return n}function gn(e){const{anchor:t,frame:n,offset:i,orient:r,angle:o,limit:a,color:s,subtitleColor:l,subtitleFont:c,subtitleFontSize:f,subtitleFontStyle:d,subtitleFontWeight:m,subtitleLineHeight:p,subtitlePadding:g,...h}=e,y={...t?{anchor:t}:{},...n?{frame:n}:{},...i?{offset:i}:{},...r?{orient:r}:{},...void 0!==o?{angle:o}:{},...void 0!==a?{limit:a}:{}},v={...l?{subtitleColor:l}:{},...c?{subtitleFont:c}:{},...f?{subtitleFontSize:f}:{},...d?{subtitleFontStyle:d}:{},...m?{subtitleFontWeight:m}:{},...p?{subtitleLineHeight:p}:{},...g?{subtitlePadding:g}:{}};return{titleMarkConfig:{...h,...s?{fill:s}:{}},subtitleMarkConfig:u(e,["align","baseline","dx","dy","limit"]),nonMarkTitleProperties:y,subtitle:v}}function hn(e){return t.isString(e)||t.isArray(e)&&t.isString(e[0])}function yn(e){return!!e?.signal}function vn(e){return!!e.step}function bn(e){return!t.isArray(e)&&("field"in e&&"data"in e)}const xn=D({aria:1,description:1,ariaRole:1,ariaRoleDescription:1,blend:1,opacity:1,fill:1,fillOpacity:1,stroke:1,strokeCap:1,strokeWidth:1,strokeOpacity:1,strokeDash:1,strokeDashOffset:1,strokeJoin:1,strokeOffset:1,strokeMiterLimit:1,startAngle:1,endAngle:1,padAngle:1,innerRadius:1,outerRadius:1,size:1,shape:1,interpolate:1,tension:1,orient:1,align:1,baseline:1,text:1,dir:1,dx:1,dy:1,ellipsis:1,limit:1,radius:1,theta:1,angle:1,font:1,fontSize:1,fontWeight:1,fontStyle:1,lineBreak:1,lineHeight:1,cursor:1,href:1,tooltip:1,cornerRadius:1,cornerRadiusTopLeft:1,cornerRadiusTopRight:1,cornerRadiusBottomLeft:1,cornerRadiusBottomRight:1,aspect:1,width:1,height:1,url:1,smooth:1}),$n={arc:1,area:1,group:1,image:1,line:1,path:1,rect:1,rule:1,shape:1,symbol:1,text:1,trail:1},wn=["cornerRadius","cornerRadiusTopLeft","cornerRadiusTopRight","cornerRadiusBottomLeft","cornerRadiusBottomRight"];function kn(e){const n=t.isArray(e.condition)?e.condition.map(Dn):Dn(e.condition);return{...Sn(e),condition:n}}function Sn(e){if(mn(e)){const{expr:t,...n}=e;return{signal:t,...n}}return e}function Dn(e){if(mn(e)){const{expr:t,...n}=e;return{signal:t,...n}}return e}function Fn(e){if(mn(e)){const{expr:t,...n}=e;return{signal:t,...n}}return yn(e)?e:void 0!==e?{value:e}:void 0}function zn(e){return yn(e)?e.signal:t.stringValue(e.value)}function On(e){return yn(e)?e.signal:null==e?null:t.stringValue(e)}function _n(e,t,n){for(const i of n){const n=Pn(i,t.markDef,t.config);void 0!==n&&(e[i]=Fn(n))}return e}function Cn(e){return[].concat(e.type,e.style??[])}function Nn(e,t,n){let i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};const{vgChannel:r,ignoreVgConfig:o}=i;return r&&void 0!==t[r]?t[r]:void 0!==t[e]?t[e]:!o||r&&r!==e?Pn(e,t,n,i):void 0}function Pn(e,t,n){let{vgChannel:i}=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};return U(i?An(e,t,n.style):void 0,An(e,t,n.style),i?n[t.type][i]:void 0,n[t.type][e],i?n.mark[i]:n.mark[e])}function An(e,t,n){return jn(e,Cn(t),n)}function jn(e,n,i){let r;n=t.array(n);for(const t of n){const n=i[t];n&&void 0!==n[e]&&(r=n[e])}return r}function Tn(e,n){return t.array(e).reduce(((e,t)=>(e.field.push(ta(t,n)),e.order.push(t.sort??"ascending"),e)),{field:[],order:[]})}function En(e,t){const n=[...e];return t.forEach((e=>{for(const t of n)if(Y(t,e))return;n.push(e)})),n}function Mn(e,n){return Y(e,n)||!n?e:e?[...t.array(e),...t.array(n)].join(", "):n}function Ln(e,t){const n=e.value,i=t.value;if(null==n||null===i)return{explicit:e.explicit,value:null};if((hn(n)||yn(n))&&(hn(i)||yn(i)))return{explicit:e.explicit,value:Mn(n,i)};if(hn(n)||yn(n))return{explicit:e.explicit,value:n};if(hn(i)||yn(i))return{explicit:e.explicit,value:i};if(!(hn(n)||yn(n)||hn(i)||yn(i)))return{explicit:e.explicit,value:En(n,i)};throw new Error("It should never reach here")}function qn(e){return`Invalid specification ${X(e)}. Make sure the specification includes at least one of the following properties: "mark", "layer", "facet", "hconcat", "vconcat", "concat", or "repeat".`}const Un='Autosize "fit" only works for single views and layered views.';function Rn(e){return`${"width"==e?"Width":"Height"} "container" only works for single views and layered views.`}function Wn(e){return`${"width"==e?"Width":"Height"} "container" only works well with autosize "fit" or "fit-${"width"==e?"x":"y"}".`}function Bn(e){return e?`Dropping "fit-${e}" because spec has discrete ${rt(e)}.`:'Dropping "fit" because spec has discrete size.'}function In(e){return`Unknown field for ${e}. Cannot calculate view size.`}function Hn(e){return`Cannot project a selection on encoding channel "${e}", which has no field.`}function Vn(e,t){return`Cannot project a selection on encoding channel "${e}" as it uses an aggregate function ("${t}").`}function Gn(e){return`Selection not supported for ${e} yet.`}const Yn="The same selection must be used to override scale domains in a layered view.";function Xn(e){return`The "columns" property cannot be used when "${e}" has nested row/column.`}function Qn(e,t,n){return`An ancestor parsed field "${e}" as ${n} but a child wants to parse the field as ${t}.`}function Jn(e){return`Config.customFormatTypes is not true, thus custom format type and format for channel ${e} are dropped.`}function Kn(e){return`${e}Offset dropped because ${e} is continuous`}function Zn(e){return`Invalid field type "${e}".`}function ei(e,t){const{fill:n,stroke:i}=t;return`Dropping color ${e} as the plot also has ${n&&i?"fill and stroke":n?"fill":"stroke"}.`}function ti(e,t){return`Dropping ${X(e)} from channel "${t}" since it does not contain any data field, datum, value, or signal.`}function ni(e,t,n){return`${e} dropped as it is incompatible with "${t}".`}function ii(e){return`${e} encoding should be discrete (ordinal / nominal / binned).`}function ri(e){return`${e} encoding should be discrete (ordinal / nominal / binned) or use a discretizing scale (e.g. threshold).`}function oi(e,t){return`Using discrete channel "${e}" to encode "${t}" field can be misleading as it does not encode ${"ordinal"===t?"order":"magnitude"}.`}function ai(e){return`Using unaggregated domain with raw field has no effect (${X(e)}).`}function si(e){return`Unaggregated domain not applicable for "${e}" since it produces values outside the origin domain of the source data.`}function li(e){return`Unaggregated domain is currently unsupported for log scale (${X(e)}).`}function ci(e,t,n){return`${n}-scale's "${t}" is dropped as it does not work with ${e} scale.`}function ui(e){return`The step for "${e}" is dropped because the ${"width"===e?"x":"y"} is continuous.`}const fi="Domains that should be unioned has conflicting sort properties. Sort will be set to true.";function di(e,t){return`Invalid ${e}: ${X(t)}.`}function mi(e){return`1D error band does not support ${e}.`}function pi(e){return`Channel ${e} is required for "binned" bin.`}const gi=t.logger(t.Warn);let hi=gi;function yi(){hi.warn(...arguments)}function vi(e){if(e&&t.isObject(e))for(const t of Fi)if(t in e)return!0;return!1}const bi=["january","february","march","april","may","june","july","august","september","october","november","december"],xi=bi.map((e=>e.substr(0,3))),$i=["sunday","monday","tuesday","wednesday","thursday","friday","saturday"],wi=$i.map((e=>e.substr(0,3)));function ki(e,n){const i=[];if(n&&void 0!==e.day&&D(e).length>1&&(yi(function(e){return`Dropping day from datetime ${X(e)} as day cannot be combined with other units.`}(e)),delete(e=l(e)).day),void 0!==e.year?i.push(e.year):i.push(2012),void 0!==e.month){const r=n?function(e){if(V(e)&&(e=+e),t.isNumber(e))return e-1;{const t=e.toLowerCase(),n=bi.indexOf(t);if(-1!==n)return n;const i=t.substr(0,3),r=xi.indexOf(i);if(-1!==r)return r;throw new Error(di("month",e))}}(e.month):e.month;i.push(r)}else if(void 0!==e.quarter){const r=n?function(e){if(V(e)&&(e=+e),t.isNumber(e))return e>4&&yi(di("quarter",e)),e-1;throw new Error(di("quarter",e))}(e.quarter):e.quarter;i.push(t.isNumber(r)?3*r:`${r}*3`)}else i.push(0);if(void 0!==e.date)i.push(e.date);else if(void 0!==e.day){const r=n?function(e){if(V(e)&&(e=+e),t.isNumber(e))return e%7;{const t=e.toLowerCase(),n=$i.indexOf(t);if(-1!==n)return n;const i=t.substr(0,3),r=wi.indexOf(i);if(-1!==r)return r;throw new Error(di("day",e))}}(e.day):e.day;i.push(t.isNumber(r)?r+1:`${r}+1`)}else i.push(1);for(const t of["hours","minutes","seconds","milliseconds"]){const n=e[t];i.push(void 0===n?0:n)}return i}function Si(e){const t=ki(e,!0).join(", ");return e.utc?`utc(${t})`:`datetime(${t})`}const Di={year:1,quarter:1,month:1,week:1,day:1,dayofyear:1,date:1,hours:1,minutes:1,seconds:1,milliseconds:1},Fi=D(Di);function zi(e){return t.isObject(e)?e.binned:Oi(e)}function Oi(e){return e&&e.startsWith("binned")}function _i(e){return e.startsWith("utc")}const Ci={"year-month":"%b %Y ","year-month-date":"%b %d, %Y "};function Ni(e){return Fi.filter((t=>Ai(e,t)))}function Pi(e){const t=Ni(e);return t[t.length-1]}function Ai(e,t){const n=e.indexOf(t);return!(n<0)&&(!(n>0&&"seconds"===t&&"i"===e.charAt(n-1))&&(!(e.length>n+3&&"day"===t&&"o"===e.charAt(n+3))&&!(n>0&&"year"===t&&"f"===e.charAt(n-1))))}function ji(e,t){let{end:n}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{end:!1};const i=A(t),r=_i(e)?"utc":"";let o;const a={};for(const t of Fi)Ai(e,t)&&(a[t]="quarter"===(s=t)?`(${r}quarter(${i})-1)`:`${r}${s}(${i})`,o=t);var s;return n&&(a[o]+="+1"),function(e){const t=ki(e,!1).join(", ");return e.utc?`utc(${t})`:`datetime(${t})`}(a)}function Ti(e){if(!e)return;return`timeUnitSpecifier(${X(Ni(e))}, ${X(Ci)})`}function Ei(e){if(!e)return;let n;return t.isString(e)?n=Oi(e)?{unit:e.substring(6),binned:!0}:{unit:e}:t.isObject(e)&&(n={...e,...e.unit?{unit:e.unit}:{}}),_i(n.unit)&&(n.utc=!0,n.unit=n.unit.substring(3)),n}function Mi(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:e=>e;const n=Ei(e),i=Pi(n.unit);if(i&&"day"!==i){const e={year:2001,month:1,date:1,hours:0,minutes:0,seconds:0,milliseconds:0},{step:r,part:o}=qi(i,n.step);return`${t(Si({...e,[o]:+e[o]+r}))} - ${t(Si(e))}`}}const Li={year:1,month:1,date:1,hours:1,minutes:1,seconds:1,milliseconds:1};function qi(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1;if(function(e){return!!Li[e]}(e))return{part:e,step:t};switch(e){case"day":case"dayofyear":return{part:"date",step:t};case"quarter":return{part:"month",step:3*t};case"week":return{part:"date",step:7*t}}}function Ui(e){return!!e?.field&&void 0!==e.equal}function Ri(e){return!!e?.field&&void 0!==e.lt}function Wi(e){return!!e?.field&&void 0!==e.lte}function Bi(e){return!!e?.field&&void 0!==e.gt}function Ii(e){return!!e?.field&&void 0!==e.gte}function Hi(e){if(e?.field){if(t.isArray(e.range)&&2===e.range.length)return!0;if(yn(e.range))return!0}return!1}function Vi(e){return!!e?.field&&(t.isArray(e.oneOf)||t.isArray(e.in))}function Gi(e){return Vi(e)||Ui(e)||Hi(e)||Ri(e)||Bi(e)||Wi(e)||Ii(e)}function Yi(e,t){return va(e,{timeUnit:t,wrapTime:!0})}function Xi(e){let t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];const{field:n}=e,i=Ei(e.timeUnit),{unit:r,binned:o}=i||{},a=ta(e,{expr:"datum"}),s=r?`time(${o?a:ji(r,n)})`:a;if(Ui(e))return`${s}===${Yi(e.equal,r)}`;if(Ri(e)){return`${s}<${Yi(e.lt,r)}`}if(Bi(e)){return`${s}>${Yi(e.gt,r)}`}if(Wi(e)){return`${s}<=${Yi(e.lte,r)}`}if(Ii(e)){return`${s}>=${Yi(e.gte,r)}`}if(Vi(e))return`indexof([${function(e,t){return e.map((e=>Yi(e,t)))}(e.oneOf,r).join(",")}], ${s}) !== -1`;if(function(e){return!!e?.field&&void 0!==e.valid}(e))return Qi(s,e.valid);if(Hi(e)){const{range:n}=e,i=yn(n)?{signal:`${n.signal}[0]`}:n[0],o=yn(n)?{signal:`${n.signal}[1]`}:n[1];if(null!==i&&null!==o&&t)return"inrange("+s+", ["+Yi(i,r)+", "+Yi(o,r)+"])";const a=[];return null!==i&&a.push(`${s} >= ${Yi(i,r)}`),null!==o&&a.push(`${s} <= ${Yi(o,r)}`),a.length>0?a.join(" && "):"true"}throw new Error(`Invalid field predicate: ${X(e)}`)}function Qi(e){return!(arguments.length>1&&void 0!==arguments[1])||arguments[1]?`isValid(${e}) && isFinite(+${e})`:`!isValid(${e}) || !isFinite(+${e})`}function Ji(e){return Gi(e)&&e.timeUnit?{...e,timeUnit:Ei(e.timeUnit)}:e}function Ki(e){return"quantitative"===e||"temporal"===e}function Zi(e){return"ordinal"===e||"nominal"===e}const er="quantitative",tr="ordinal",nr="temporal",ir="nominal",rr="geojson";const or={LINEAR:"linear",LOG:"log",POW:"pow",SQRT:"sqrt",SYMLOG:"symlog",IDENTITY:"identity",SEQUENTIAL:"sequential",TIME:"time",UTC:"utc",QUANTILE:"quantile",QUANTIZE:"quantize",THRESHOLD:"threshold",BIN_ORDINAL:"bin-ordinal",ORDINAL:"ordinal",POINT:"point",BAND:"band"},ar={linear:"numeric",log:"numeric",pow:"numeric",sqrt:"numeric",symlog:"numeric",identity:"numeric",sequential:"numeric",time:"time",utc:"time",ordinal:"ordinal","bin-ordinal":"bin-ordinal",point:"ordinal-position",band:"ordinal-position",quantile:"discretizing",quantize:"discretizing",threshold:"discretizing"};function sr(e,t){const n=ar[e],i=ar[t];return n===i||"ordinal-position"===n&&"time"===i||"ordinal-position"===i&&"time"===n}const lr={linear:0,log:1,pow:1,sqrt:1,symlog:1,identity:1,sequential:1,time:0,utc:0,point:10,band:11,ordinal:0,"bin-ordinal":0,quantile:0,quantize:0,threshold:0};function cr(e){return lr[e]}const ur=new Set(["linear","log","pow","sqrt","symlog"]),fr=new Set([...ur,"time","utc"]);function dr(e){return ur.has(e)}const mr=new Set(["quantile","quantize","threshold"]),pr=new Set([...fr,...mr,"sequential","identity"]),gr=new Set(["ordinal","bin-ordinal","point","band"]);function hr(e){return gr.has(e)}function yr(e){return pr.has(e)}function vr(e){return fr.has(e)}function br(e){return mr.has(e)}function xr(e){return e?.param}const{type:$r,domain:wr,range:kr,rangeMax:Sr,rangeMin:Dr,scheme:Fr,...zr}={type:1,domain:1,domainMax:1,domainMin:1,domainMid:1,domainRaw:1,align:1,range:1,rangeMax:1,rangeMin:1,scheme:1,bins:1,reverse:1,round:1,clamp:1,nice:1,base:1,exponent:1,constant:1,interpolate:1,zero:1,padding:1,paddingInner:1,paddingOuter:1},Or=D(zr);function _r(e,t){switch(t){case"type":case"domain":case"reverse":case"range":return!0;case"scheme":case"interpolate":return!["point","band","identity"].includes(e);case"bins":return!["point","band","identity","ordinal"].includes(e);case"round":return vr(e)||"band"===e||"point"===e;case"padding":case"rangeMin":case"rangeMax":return vr(e)||["point","band"].includes(e);case"paddingOuter":case"align":return["point","band"].includes(e);case"paddingInner":return"band"===e;case"domainMax":case"domainMid":case"domainMin":case"domainRaw":case"clamp":return vr(e);case"nice":return vr(e)||"quantize"===e||"threshold"===e;case"exponent":return"pow"===e;case"base":return"log"===e;case"constant":return"symlog"===e;case"zero":return yr(e)&&!p(["log","time","utc","threshold","quantile"],e)}}function Cr(e,t){switch(t){case"interpolate":case"scheme":case"domainMid":return qe(e)?void 0:`Cannot use the scale property "${t}" with non-color channel.`;case"align":case"type":case"bins":case"domain":case"domainMax":case"domainMin":case"domainRaw":case"range":case"base":case"exponent":case"constant":case"nice":case"padding":case"paddingInner":case"paddingOuter":case"rangeMax":case"rangeMin":case"reverse":case"round":case"clamp":case"zero":return}}function Nr(e){const{channel:t,channelDef:n,markDef:i,scale:r,config:o}=e,a=Er(e);return Ro(n)&&!rn(n.aggregate)&&r&&vr(r.get("type"))?function(e){let{fieldDef:t,channel:n,markDef:i,ref:r,config:o}=e;const a=Nn("invalid",i,o);if(null===a)return[Pr(t,n),r];return r}({fieldDef:n,channel:t,markDef:i,ref:a,config:o}):a}function Pr(e,t){return{test:Ar(e,!0),..."y"===tt(t)?{field:{group:"height"}}:{value:0}}}function Ar(e){let n=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return Qi(t.isString(e)?e:ta(e,{expr:"datum"}),!n)}function jr(e,t,n,i){const r={};if(t&&(r.scale=t),Bo(e)){const{datum:t}=e;vi(t)?r.signal=Si(t):yn(t)?r.signal=t.signal:mn(t)?r.signal=t.expr:r.value=t}else r.field=ta(e,n);if(i){const{offset:e,band:t}=i;e&&(r.offset=e),t&&(r.band=t)}return r}function Tr(e){let{scaleName:t,fieldOrDatumDef:n,fieldOrDatumDef2:i,offset:r,startSuffix:o,endSuffix:a="end",bandPosition:s=.5}=e;const l=!yn(s)&&01&&void 0!==arguments[1]?arguments[1]:{},n=e.field;const i=t.prefix;let r=t.suffix,o="";if(function(e){return"count"===e.aggregate}(e))n=B("count");else{let i;if(!t.nofn)if(function(e){return"op"in e}(e))i=e.op;else{const{bin:a,aggregate:s,timeUnit:l}=e;ln(a)?(i=sn(a),r=(t.binSuffix??"")+(t.suffix??"")):s?en(s)?(o=`["${n}"]`,n=`argmax_${s.argmax}`):Zt(s)?(o=`["${n}"]`,n=`argmin_${s.argmin}`):i=String(s):l&&!zi(l)&&(i=function(e){const{utc:t,...n}=Ei(e);return n.unit?(t?"utc":"")+D(n).map((e=>_(`${"unit"===e?"":`_${e}_`}${n[e]}`))).join(""):(t?"utc":"")+"timeunit"+D(n).map((e=>_(`_${e}_${n[e]}`))).join("")}(l),r=(!["range","mid"].includes(t.binSuffix)&&t.binSuffix||"")+(t.suffix??""))}i&&(n=n?`${i}_${n}`:i)}return r&&(n=`${n}_${r}`),i&&(n=`${i}_${n}`),t.forAs?L(n):t.expr?j(n,t.expr)+o:E(n)+o}function na(e){switch(e.type){case"nominal":case"ordinal":case"geojson":return!0;case"quantitative":return Ro(e)&&!!e.bin;case"temporal":return!1}throw new Error(Zn(e.type))}const ia=(e,t)=>{switch(t.fieldTitle){case"plain":return e.field;case"functional":return function(e){const{aggregate:t,bin:n,timeUnit:i,field:r}=e;if(en(t))return`${r} for argmax(${t.argmax})`;if(Zt(t))return`${r} for argmin(${t.argmin})`;const o=i&&!zi(i)?Ei(i):void 0,a=t||o?.unit||o?.maxbins&&"timeunit"||ln(n)&&"bin";return a?`${a.toUpperCase()}(${r})`:r}(e);default:return function(e,t){const{field:n,bin:i,timeUnit:r,aggregate:o}=e;if("count"===o)return t.countTitle;if(ln(i))return`${n} (binned)`;if(r&&!zi(r)){const e=Ei(r)?.unit;if(e)return`${n} (${Ni(e).join("-")})`}else if(o)return en(o)?`${n} for max ${o.argmax}`:Zt(o)?`${n} for min ${o.argmin}`:`${P(o)} of ${n}`;return n}(e,t)}};let ra=ia;function oa(e){ra=e}function aa(e,t,n){let{allowDisabling:i,includeDefault:r=!0}=n;const o=sa(e)?.title;if(!Ro(e))return o??e.title;const a=e,s=r?la(a,t):void 0;return i?U(o,a.title,s):o??a.title??s}function sa(e){return Jo(e)&&e.axis?e.axis:Ko(e)&&e.legend?e.legend:Co(e)&&e.header?e.header:void 0}function la(e,t){return ra(e,t)}function ca(e){if(Zo(e)){const{format:t,formatType:n}=e;return{format:t,formatType:n}}{const t=sa(e)??{},{format:n,formatType:i}=t;return{format:n,formatType:i}}}function ua(e){return Ro(e)?e:qo(e)?e.condition:void 0}function fa(e){return Go(e)?e:Uo(e)?e.condition:void 0}function da(e,n,i){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};if(t.isString(e)||t.isNumber(e)||t.isBoolean(e)){return yi(function(e,t,n){return`Channel ${e} is a ${t}. Converted to {value: ${X(n)}}.`}(n,t.isString(e)?"string":t.isNumber(e)?"number":"boolean",e)),{value:e}}return Go(e)?ma(e,n,i,r):Uo(e)?{...e,condition:ma(e.condition,n,i,r)}:e}function ma(e,n,i,r){if(Zo(e)){const{format:t,formatType:o,...a}=e;if(Lr(o)&&!i.customFormatTypes)return yi(Jn(n)),ma(a,n,i,r)}else{const t=Jo(e)?"axis":Ko(e)?"legend":Co(e)?"header":null;if(t&&e[t]){const{format:o,formatType:a,...s}=e[t];if(Lr(a)&&!i.customFormatTypes)return yi(Jn(n)),ma({...e,[t]:s},n,i,r)}}return Ro(e)?pa(e,n,r):function(e){let n=e.type;if(n)return e;const{datum:i}=e;return n=t.isNumber(i)?"quantitative":t.isString(i)?"nominal":vi(i)?"temporal":void 0,{...e,type:n}}(e)}function pa(e,n){let{compositeMark:i=!1}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{aggregate:r,timeUnit:o,bin:a,field:s}=e,l={...e};if(i||!r||tn(r)||en(r)||Zt(r)||(yi(function(e){return`Invalid aggregation operator "${e}".`}(r)),delete l.aggregate),o&&(l.timeUnit=Ei(o)),s&&(l.field=`${s}`),ln(a)&&(l.bin=ga(a,n)),cn(a)&&!zt(n)&&yi(function(e){return`Channel ${e} should not be used with "binned" bin.`}(n)),Yo(l)){const{type:e}=l,t=function(e){if(e)switch(e=e.toLowerCase()){case"q":case er:return"quantitative";case"t":case nr:return"temporal";case"o":case tr:return"ordinal";case"n":case ir:return"nominal";case rr:return"geojson"}}(e);e!==t&&(l.type=t),"quantitative"!==e&&rn(r)&&(yi(function(e,t){return`Invalid field type "${e}" for aggregate: "${t}", using "quantitative" instead.`}(e,r)),l.type="quantitative")}else if(!et(n)){const e=function(e,n){switch(n){case"latitude":case"longitude":return"quantitative";case"row":case"column":case"facet":case"shape":case"strokeDash":return"nominal";case"order":return"ordinal"}if(Ao(e)&&t.isArray(e.sort))return"ordinal";const{aggregate:i,bin:r,timeUnit:o}=e;if(o)return"temporal";if(r||i&&!en(i)&&!Zt(i))return"quantitative";if(Qo(e)&&e.scale?.type)switch(ar[e.scale.type]){case"numeric":case"discretizing":return"quantitative";case"time":return"temporal"}return"nominal"}(l,n);l.type=e}if(Yo(l)){const{compatible:e,warning:t}=function(e,t){const n=e.type;if("geojson"===n&&"shape"!==t)return{compatible:!1,warning:`Channel ${t} should not be used with a geojson data.`};switch(t){case Q:case J:case K:return na(e)?ha:{compatible:!1,warning:ii(t)};case Z:case ee:case ie:case re:case me:case pe:case ge:case Se:case Fe:case ze:case Oe:case _e:case Ce:case ve:case se:case oe:case Ne:return ha;case ue:case de:case ce:case fe:return n!==er?{compatible:!1,warning:`Channel ${t} should be used with a quantitative field only, not ${e.type} field.`}:ha;case be:case xe:case $e:case we:case ye:case le:case ae:case te:case ne:return"nominal"!==n||e.sort?ha:{compatible:!1,warning:`Channel ${t} should not be used with an unsorted discrete field.`};case he:case ke:return na(e)||Qo(i=e)&&br(i.scale?.type)?ha:{compatible:!1,warning:ri(t)};case De:return"nominal"!==e.type||"sort"in e?ha:{compatible:!1,warning:"Channel order is inappropriate for nominal field, which has no inherent order."}}var i}(l,n)||{};!1===e&&yi(t)}if(Ao(l)&&t.isString(l.sort)){const{sort:e}=l;if(Do(e))return{...l,sort:{encoding:e}};const t=e.substr(1);if("-"===e.charAt(0)&&Do(t))return{...l,sort:{encoding:t,order:"descending"}}}if(Co(l)){const{header:e}=l;if(e){const{orient:t,...n}=e;if(t)return{...l,header:{...n,labelOrient:e.labelOrient||t,titleOrient:e.titleOrient||t}}}}return l}function ga(e,n){return t.isBoolean(e)?{maxbins:dn(n)}:"binned"===e?{binned:!0}:e.maxbins||e.step?e:{...e,maxbins:dn(n)}}const ha={compatible:!0};function ya(e){const{formatType:t}=ca(e);return"time"===t||!t&&((n=e)&&("temporal"===n.type||Ro(n)&&!!n.timeUnit));var n}function va(e,n){let{timeUnit:i,type:r,wrapTime:o,undefinedIfExprNotRequired:a}=n;const s=i&&Ei(i)?.unit;let l,c=s||"temporal"===r;return mn(e)?l=e.expr:yn(e)?l=e.signal:vi(e)?(c=!0,l=Si(e)):(t.isString(e)||t.isNumber(e))&&c&&(l=`datetime(${X(e)})`,function(e){return!!Di[e]}(s)&&(t.isNumber(e)&&e<1e4||t.isString(e)&&isNaN(Date.parse(e)))&&(l=Si({[s]:e}))),l?o&&c?`time(${l})`:l:a?void 0:X(e)}function ba(e,t){const{type:n}=e;return t.map((t=>{const i=va(t,{timeUnit:Ro(e)&&!zi(e.timeUnit)?e.timeUnit:void 0,type:n,undefinedIfExprNotRequired:!0});return void 0!==i?{signal:i}:t}))}function xa(e,t){return ln(e.bin)?Ht(t)&&["ordinal","nominal"].includes(e.type):(console.warn("Only call this method for binned field defs."),!1)}const $a={labelAlign:{part:"labels",vgProp:"align"},labelBaseline:{part:"labels",vgProp:"baseline"},labelColor:{part:"labels",vgProp:"fill"},labelFont:{part:"labels",vgProp:"font"},labelFontSize:{part:"labels",vgProp:"fontSize"},labelFontStyle:{part:"labels",vgProp:"fontStyle"},labelFontWeight:{part:"labels",vgProp:"fontWeight"},labelOpacity:{part:"labels",vgProp:"opacity"},labelOffset:null,labelPadding:null,gridColor:{part:"grid",vgProp:"stroke"},gridDash:{part:"grid",vgProp:"strokeDash"},gridDashOffset:{part:"grid",vgProp:"strokeDashOffset"},gridOpacity:{part:"grid",vgProp:"opacity"},gridWidth:{part:"grid",vgProp:"strokeWidth"},tickColor:{part:"ticks",vgProp:"stroke"},tickDash:{part:"ticks",vgProp:"strokeDash"},tickDashOffset:{part:"ticks",vgProp:"strokeDashOffset"},tickOpacity:{part:"ticks",vgProp:"opacity"},tickSize:null,tickWidth:{part:"ticks",vgProp:"strokeWidth"}};function wa(e){return e?.condition}const ka=["domain","grid","labels","ticks","title"],Sa={grid:"grid",gridCap:"grid",gridColor:"grid",gridDash:"grid",gridDashOffset:"grid",gridOpacity:"grid",gridScale:"grid",gridWidth:"grid",orient:"main",bandPosition:"both",aria:"main",description:"main",domain:"main",domainCap:"main",domainColor:"main",domainDash:"main",domainDashOffset:"main",domainOpacity:"main",domainWidth:"main",format:"main",formatType:"main",labelAlign:"main",labelAngle:"main",labelBaseline:"main",labelBound:"main",labelColor:"main",labelFlush:"main",labelFlushOffset:"main",labelFont:"main",labelFontSize:"main",labelFontStyle:"main",labelFontWeight:"main",labelLimit:"main",labelLineHeight:"main",labelOffset:"main",labelOpacity:"main",labelOverlap:"main",labelPadding:"main",labels:"main",labelSeparation:"main",maxExtent:"main",minExtent:"main",offset:"both",position:"main",tickCap:"main",tickColor:"main",tickDash:"main",tickDashOffset:"main",tickMinStep:"both",tickOffset:"both",tickOpacity:"main",tickRound:"both",ticks:"main",tickSize:"main",tickWidth:"both",title:"main",titleAlign:"main",titleAnchor:"main",titleAngle:"main",titleBaseline:"main",titleColor:"main",titleFont:"main",titleFontSize:"main",titleFontStyle:"main",titleFontWeight:"main",titleLimit:"main",titleLineHeight:"main",titleOpacity:"main",titlePadding:"main",titleX:"main",titleY:"main",encode:"both",scale:"both",tickBand:"both",tickCount:"both",tickExtra:"both",translate:"both",values:"both",zindex:"both"},Da={orient:1,aria:1,bandPosition:1,description:1,domain:1,domainCap:1,domainColor:1,domainDash:1,domainDashOffset:1,domainOpacity:1,domainWidth:1,format:1,formatType:1,grid:1,gridCap:1,gridColor:1,gridDash:1,gridDashOffset:1,gridOpacity:1,gridWidth:1,labelAlign:1,labelAngle:1,labelBaseline:1,labelBound:1,labelColor:1,labelFlush:1,labelFlushOffset:1,labelFont:1,labelFontSize:1,labelFontStyle:1,labelFontWeight:1,labelLimit:1,labelLineHeight:1,labelOffset:1,labelOpacity:1,labelOverlap:1,labelPadding:1,labels:1,labelSeparation:1,maxExtent:1,minExtent:1,offset:1,position:1,tickBand:1,tickCap:1,tickColor:1,tickCount:1,tickDash:1,tickDashOffset:1,tickExtra:1,tickMinStep:1,tickOffset:1,tickOpacity:1,tickRound:1,ticks:1,tickSize:1,tickWidth:1,title:1,titleAlign:1,titleAnchor:1,titleAngle:1,titleBaseline:1,titleColor:1,titleFont:1,titleFontSize:1,titleFontStyle:1,titleFontWeight:1,titleLimit:1,titleLineHeight:1,titleOpacity:1,titlePadding:1,titleX:1,titleY:1,translate:1,values:1,zindex:1},Fa={...Da,style:1,labelExpr:1,encoding:1};function za(e){return!!Fa[e]}const Oa=D({axis:1,axisBand:1,axisBottom:1,axisDiscrete:1,axisLeft:1,axisPoint:1,axisQuantitative:1,axisRight:1,axisTemporal:1,axisTop:1,axisX:1,axisXBand:1,axisXDiscrete:1,axisXPoint:1,axisXQuantitative:1,axisXTemporal:1,axisY:1,axisYBand:1,axisYDiscrete:1,axisYPoint:1,axisYQuantitative:1,axisYTemporal:1});function _a(e){return"mark"in e}class Ca{constructor(e,t){this.name=e,this.run=t}hasMatchingType(e){return!!_a(e)&&(go(t=e.mark)?t.type:t)===this.name;var t}}function Na(e,n){const i=e&&e[n];return!!i&&(t.isArray(i)?g(i,(e=>!!e.field)):Ro(i)||qo(i))}function Pa(e,n){const i=e&&e[n];return!!i&&(t.isArray(i)?g(i,(e=>!!e.field)):Ro(i)||Bo(i)||Uo(i))}function Aa(e,t){if(zt(t)){const n=e[t];if((Ro(n)||Bo(n))&&(Zi(n.type)||Ro(n)&&n.timeUnit)){return Pa(e,at(t))}}return!1}function ja(e){return g(Be,(n=>{if(Na(e,n)){const i=e[n];if(t.isArray(i))return g(i,(e=>!!e.aggregate));{const e=ua(i);return e&&!!e.aggregate}}return!1}))}function Ta(e,t){const n=[],i=[],r=[],o=[],a={};return La(e,((s,l)=>{if(Ro(s)){const{field:c,aggregate:u,bin:f,timeUnit:d,...m}=s;if(u||d||f){const e=sa(s),p=e?.title;let g=ta(s,{forAs:!0});const h={...p?[]:{title:aa(s,t,{allowDisabling:!0})},...m,field:g};if(u){let e;if(en(u)?(e="argmax",g=ta({op:"argmax",field:u.argmax},{forAs:!0}),h.field=`${g}.${c}`):Zt(u)?(e="argmin",g=ta({op:"argmin",field:u.argmin},{forAs:!0}),h.field=`${g}.${c}`):"boxplot"!==u&&"errorbar"!==u&&"errorband"!==u&&(e=u),e){const t={op:e,as:g};c&&(t.field=c),o.push(t)}}else if(n.push(g),Yo(s)&&ln(f)){if(i.push({bin:f,field:c,as:g}),n.push(ta(s,{binSuffix:"end"})),xa(s,l)&&n.push(ta(s,{binSuffix:"range"})),zt(l)){const e={field:`${g}_end`};a[`${l}2`]=e}h.bin="binned",et(l)||(h.type=er)}else if(d&&!zi(d)){r.push({timeUnit:d,field:c,as:g});const e=Yo(s)&&s.type!==nr&&"time";e&&(l===Se||l===Oe?h.formatType=e:!function(e){return!!kt[e]}(l)?zt(l)&&(h.axis={formatType:e,...h.axis}):h.legend={formatType:e,...h.legend})}a[l]=h}else n.push(c),a[l]=e[l]}else a[l]=e[l]})),{bins:i,timeUnits:r,aggregate:o,groupby:n,encoding:a}}function Ea(e,t,n){const i=Vt(t,n);if(!i)return!1;if("binned"===i){const n=e[t===te?Z:ee];return!!(Ro(n)&&Ro(e[t])&&cn(n.bin))}return!0}function Ma(e,t){const n={};for(const i of D(e)){const r=da(e[i],i,t,{compositeMark:!0});n[i]=r}return n}function La(e,n,i){if(e)for(const r of D(e)){const o=e[r];if(t.isArray(o))for(const e of o)n.call(i,e,r);else n.call(i,o,r)}}function qa(e,n){return D(n).reduce(((i,r)=>{switch(r){case Z:case ee:case _e:case Ne:case Ce:case te:case ne:case ie:case re:case se:case le:case oe:case ae:case ce:case ue:case fe:case de:case Se:case he:case ve:case Oe:return i;case De:if("line"===e||"trail"===e)return i;case Fe:case ze:{const e=n[r];if(t.isArray(e)||Ro(e))for(const n of t.array(e))n.aggregate||i.push(ta(n,{}));return i}case ye:if("trail"===e)return i;case me:case pe:case ge:case be:case xe:case $e:case ke:case we:{const e=ua(n[r]);return e&&!e.aggregate&&i.push(ta(e,{})),i}}}),[])}function Ua(e,n,i){let r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3];if("tooltip"in i)return{tooltip:i.tooltip};return{tooltip:[...e.map((e=>{let{fieldPrefix:t,titlePrefix:i}=e;const o=r?` of ${Ra(n)}`:"";return{field:t+n.field,type:n.type,title:yn(i)?{signal:`${i}"${escape(o)}"`}:i+o}})),...b(function(e){const n=[];for(const i of D(e))if(Na(e,i)){const r=e[i],o=t.array(r);for(const e of o)Ro(e)?n.push(e):qo(e)&&n.push(e.condition)}return n}(i).map(ea),d)]}}function Ra(e){const{title:t,field:n}=e;return U(t,n)}function Wa(e,n,i,r,o){const{scale:a,axis:s}=i;return l=>{let{partName:c,mark:u,positionPrefix:f,endPositionPrefix:d,extraEncoding:m={}}=l;const p=Ra(i);return Ba(e,c,o,{mark:u,encoding:{[n]:{field:`${f}_${i.field}`,type:i.type,...void 0!==p?{title:p}:{},...void 0!==a?{scale:a}:{},...void 0!==s?{axis:s}:{}},...t.isString(d)?{[`${n}2`]:{field:`${d}_${i.field}`}}:{},...r,...m}})}}function Ba(e,n,i,r){const{clip:o,color:a,opacity:s}=e,l=e.type;return e[n]||void 0===e[n]&&i[n]?[{...r,mark:{...i[n],...o?{clip:o}:{},...a?{color:a}:{},...s?{opacity:s}:{},...go(r.mark)?r.mark:{type:r.mark},style:`${l}-${String(n)}`,...t.isBoolean(e[n])?{}:e[n]}}]:[]}function Ia(e,t,n){const{encoding:i}=e,r="vertical"===t?"y":"x",o=i[r],a=i[`${r}2`],s=i[`${r}Error`],l=i[`${r}Error2`];return{continuousAxisChannelDef:Ha(o,n),continuousAxisChannelDef2:Ha(a,n),continuousAxisChannelDefError:Ha(s,n),continuousAxisChannelDefError2:Ha(l,n),continuousAxis:r}}function Ha(e,t){if(e?.aggregate){const{aggregate:n,...i}=e;return n!==t&&yi(function(e,t){return`Continuous axis should not have customized aggregation function ${e}; ${t} already agregates the axis.`}(n,t)),i}return e}function Va(e,t){const{mark:n,encoding:i}=e,{x:r,y:o}=i;if(go(n)&&n.orient)return n.orient;if(Io(r)){if(Io(o)){const e=Ro(r)&&r.aggregate,n=Ro(o)&&o.aggregate;if(e||n!==t){if(n||e!==t){if(e===t&&n===t)throw new Error("Both x and y cannot have aggregate");return ya(o)&&!ya(r)?"horizontal":"vertical"}return"horizontal"}return"vertical"}return"horizontal"}if(Io(o))return"vertical";throw new Error(`Need a valid continuous axis for ${t}s`)}const Ga="boxplot",Ya=new Ca(Ga,Qa);function Xa(e){return t.isNumber(e)?"tukey":e}function Qa(e,n){let{config:i}=n;e={...e,encoding:Ma(e.encoding,i)};const{mark:r,encoding:o,params:a,projection:s,...l}=e,c=go(r)?r:{type:r};a&&yi(Gn("boxplot"));const u=c.extent??i.boxplot.extent,d=Nn("size",c,i),m=c.invalid,p=Xa(u),{bins:g,timeUnits:h,transform:y,continuousAxisChannelDef:v,continuousAxis:b,groupby:x,aggregate:$,encodingWithoutContinuousAxis:w,ticksOrient:k,boxOrient:D,customTooltipWithoutAggregatedField:F}=function(e,n,i){const r=Va(e,Ga),{continuousAxisChannelDef:o,continuousAxis:a}=Ia(e,r,Ga),s=o.field,l=L(s),c=Xa(n),u=[...Ja(s),{op:"median",field:s,as:`mid_box_${l}`},{op:"min",field:s,as:("min-max"===c?"lower_whisker_":"min_")+l},{op:"max",field:s,as:("min-max"===c?"upper_whisker_":"max_")+l}],f="min-max"===c||"tukey"===c?[]:[{calculate:`datum["upper_box_${l}"] - datum["lower_box_${l}"]`,as:`iqr_${l}`},{calculate:`min(datum["upper_box_${l}"] + datum["iqr_${l}"] * ${n}, datum["max_${l}"])`,as:`upper_whisker_${l}`},{calculate:`max(datum["lower_box_${l}"] - datum["iqr_${l}"] * ${n}, datum["min_${l}"])`,as:`lower_whisker_${l}`}],{[a]:d,...m}=e.encoding,{customTooltipWithoutAggregatedField:p,filteredEncoding:g}=function(e){const{tooltip:n,...i}=e;if(!n)return{filteredEncoding:i};let r,o;if(t.isArray(n)){for(const e of n)e.aggregate?(r||(r=[]),r.push(e)):(o||(o=[]),o.push(e));r&&(i.tooltip=r)}else n.aggregate?i.tooltip=n:o=n;return t.isArray(o)&&1===o.length&&(o=o[0]),{customTooltipWithoutAggregatedField:o,filteredEncoding:i}}(m),{bins:h,timeUnits:y,aggregate:v,groupby:b,encoding:x}=Ta(g,i),$="vertical"===r?"horizontal":"vertical",w=r,k=[...h,...y,{aggregate:[...v,...u],groupby:b},...f];return{bins:h,timeUnits:y,transform:k,groupby:b,aggregate:v,continuousAxisChannelDef:o,continuousAxis:a,encodingWithoutContinuousAxis:x,ticksOrient:$,boxOrient:w,customTooltipWithoutAggregatedField:p}}(e,u,i),z=L(v.field),{color:O,size:_,...C}=w,N=e=>Wa(c,b,v,e,i.boxplot),P=N(C),A=N(w),j=(t.isObject(i.boxplot.box)?i.boxplot.box.color:i.mark.color)||"#4c78a8",T=N({...C,..._?{size:_}:{},color:{condition:{test:`datum['lower_box_${v.field}'] >= datum['upper_box_${v.field}']`,...O||{value:j}}}}),E=Ua([{fieldPrefix:"min-max"===p?"upper_whisker_":"max_",titlePrefix:"Max"},{fieldPrefix:"upper_box_",titlePrefix:"Q3"},{fieldPrefix:"mid_box_",titlePrefix:"Median"},{fieldPrefix:"lower_box_",titlePrefix:"Q1"},{fieldPrefix:"min-max"===p?"lower_whisker_":"min_",titlePrefix:"Min"}],v,w),M={type:"tick",color:"black",opacity:1,orient:k,invalid:m,aria:!1},q="min-max"===p?E:Ua([{fieldPrefix:"upper_whisker_",titlePrefix:"Upper Whisker"},{fieldPrefix:"lower_whisker_",titlePrefix:"Lower Whisker"}],v,w),U=[...P({partName:"rule",mark:{type:"rule",invalid:m,aria:!1},positionPrefix:"lower_whisker",endPositionPrefix:"lower_box",extraEncoding:q}),...P({partName:"rule",mark:{type:"rule",invalid:m,aria:!1},positionPrefix:"upper_box",endPositionPrefix:"upper_whisker",extraEncoding:q}),...P({partName:"ticks",mark:M,positionPrefix:"lower_whisker",extraEncoding:q}),...P({partName:"ticks",mark:M,positionPrefix:"upper_whisker",extraEncoding:q})],R=[..."tukey"!==p?U:[],...A({partName:"box",mark:{type:"bar",...d?{size:d}:{},orient:D,invalid:m,ariaRoleDescription:"box"},positionPrefix:"lower_box",endPositionPrefix:"upper_box",extraEncoding:E}),...T({partName:"median",mark:{type:"tick",invalid:m,...t.isObject(i.boxplot.median)&&i.boxplot.median.color?{color:i.boxplot.median.color}:{},...d?{size:d}:{},orient:k,aria:!1},positionPrefix:"mid_box",extraEncoding:E})];if("min-max"===p)return{...l,transform:(l.transform??[]).concat(y),layer:R};const W=`datum["lower_box_${v.field}"]`,B=`datum["upper_box_${v.field}"]`,I=`(${B} - ${W})`,H=`${W} - ${u} * ${I}`,V=`${B} + ${u} * ${I}`,G=`datum["${v.field}"]`,Y={joinaggregate:Ja(v.field),groupby:x},X={transform:[{filter:`(${H} <= ${G}) && (${G} <= ${V})`},{aggregate:[{op:"min",field:v.field,as:`lower_whisker_${z}`},{op:"max",field:v.field,as:`upper_whisker_${z}`},{op:"min",field:`lower_box_${v.field}`,as:`lower_box_${z}`},{op:"max",field:`upper_box_${v.field}`,as:`upper_box_${z}`},...$],groupby:x}],layer:U},{tooltip:Q,...J}=C,{scale:K,axis:Z}=v,ee=Ra(v),te=f(Z,["title"]),ne=Ba(c,"outliers",i.boxplot,{transform:[{filter:`(${G} < ${H}) || (${G} > ${V})`}],mark:"point",encoding:{[b]:{field:v.field,type:v.type,...void 0!==ee?{title:ee}:{},...void 0!==K?{scale:K}:{},...S(te)?{}:{axis:te}},...J,...O?{color:O}:{},...F?{tooltip:F}:{}}})[0];let ie;const re=[...g,...h,Y];return ne?ie={transform:re,layer:[ne,X]}:(ie=X,ie.transform.unshift(...re)),{...l,layer:[ie,{transform:y,layer:R}]}}function Ja(e){const t=L(e);return[{op:"q1",field:e,as:`lower_box_${t}`},{op:"q3",field:e,as:`upper_box_${t}`}]}const Ka="errorbar",Za=new Ca(Ka,es);function es(e,t){let{config:n}=t;e={...e,encoding:Ma(e.encoding,n)};const{transform:i,continuousAxisChannelDef:r,continuousAxis:o,encodingWithoutContinuousAxis:a,ticksOrient:s,markDef:l,outerSpec:c,tooltipEncoding:u}=ns(e,Ka,n);delete a.size;const f=Wa(l,o,r,a,n.errorbar),d=l.thickness,m=l.size,p={type:"tick",orient:s,aria:!1,...void 0!==d?{thickness:d}:{},...void 0!==m?{size:m}:{}},g=[...f({partName:"ticks",mark:p,positionPrefix:"lower",extraEncoding:u}),...f({partName:"ticks",mark:p,positionPrefix:"upper",extraEncoding:u}),...f({partName:"rule",mark:{type:"rule",ariaRoleDescription:"errorbar",...void 0!==d?{size:d}:{}},positionPrefix:"lower",endPositionPrefix:"upper",extraEncoding:u})];return{...c,transform:i,...g.length>1?{layer:g}:{...g[0]}}}function ts(e,t){const{encoding:n}=e;if(function(e){return(Go(e.x)||Go(e.y))&&!Go(e.x2)&&!Go(e.y2)&&!Go(e.xError)&&!Go(e.xError2)&&!Go(e.yError)&&!Go(e.yError2)}(n))return{orient:Va(e,t),inputType:"raw"};const i=function(e){return Go(e.x2)||Go(e.y2)}(n),r=function(e){return Go(e.xError)||Go(e.xError2)||Go(e.yError)||Go(e.yError2)}(n),o=n.x,a=n.y;if(i){if(r)throw new Error(`${t} cannot be both type aggregated-upper-lower and aggregated-error`);const e=n.x2,i=n.y2;if(Go(e)&&Go(i))throw new Error(`${t} cannot have both x2 and y2`);if(Go(e)){if(Io(o))return{orient:"horizontal",inputType:"aggregated-upper-lower"};throw new Error(`Both x and x2 have to be quantitative in ${t}`)}if(Go(i)){if(Io(a))return{orient:"vertical",inputType:"aggregated-upper-lower"};throw new Error(`Both y and y2 have to be quantitative in ${t}`)}throw new Error("No ranged axis")}{const e=n.xError,i=n.xError2,r=n.yError,s=n.yError2;if(Go(i)&&!Go(e))throw new Error(`${t} cannot have xError2 without xError`);if(Go(s)&&!Go(r))throw new Error(`${t} cannot have yError2 without yError`);if(Go(e)&&Go(r))throw new Error(`${t} cannot have both xError and yError with both are quantiative`);if(Go(e)){if(Io(o))return{orient:"horizontal",inputType:"aggregated-error"};throw new Error("All x, xError, and xError2 (if exist) have to be quantitative")}if(Go(r)){if(Io(a))return{orient:"vertical",inputType:"aggregated-error"};throw new Error("All y, yError, and yError2 (if exist) have to be quantitative")}throw new Error("No ranged axis")}}function ns(e,t,n){const{mark:i,encoding:r,params:o,projection:a,...s}=e,l=go(i)?i:{type:i};o&&yi(Gn(t));const{orient:c,inputType:u}=ts(e,t),{continuousAxisChannelDef:f,continuousAxisChannelDef2:d,continuousAxisChannelDefError:m,continuousAxisChannelDefError2:p,continuousAxis:g}=Ia(e,c,t),{errorBarSpecificAggregate:h,postAggregateCalculates:y,tooltipSummary:v,tooltipTitleWithFieldName:b}=function(e,t,n,i,r,o,a,s){let l=[],c=[];const u=t.field;let f,d=!1;if("raw"===o){const t=e.center?e.center:e.extent?"iqr"===e.extent?"median":"mean":s.errorbar.center,n=e.extent?e.extent:"mean"===t?"stderr":"iqr";if("median"===t!=("iqr"===n)&&yi(function(e,t,n){return`${e} is not usually used with ${t} for ${n}.`}(t,n,a)),"stderr"===n||"stdev"===n)l=[{op:n,field:u,as:`extent_${u}`},{op:t,field:u,as:`center_${u}`}],c=[{calculate:`datum["center_${u}"] + datum["extent_${u}"]`,as:`upper_${u}`},{calculate:`datum["center_${u}"] - datum["extent_${u}"]`,as:`lower_${u}`}],f=[{fieldPrefix:"center_",titlePrefix:P(t)},{fieldPrefix:"upper_",titlePrefix:is(t,n,"+")},{fieldPrefix:"lower_",titlePrefix:is(t,n,"-")}],d=!0;else{let e,t,i;"ci"===n?(e="mean",t="ci0",i="ci1"):(e="median",t="q1",i="q3"),l=[{op:t,field:u,as:`lower_${u}`},{op:i,field:u,as:`upper_${u}`},{op:e,field:u,as:`center_${u}`}],f=[{fieldPrefix:"upper_",titlePrefix:aa({field:u,aggregate:i,type:"quantitative"},s,{allowDisabling:!1})},{fieldPrefix:"lower_",titlePrefix:aa({field:u,aggregate:t,type:"quantitative"},s,{allowDisabling:!1})},{fieldPrefix:"center_",titlePrefix:aa({field:u,aggregate:e,type:"quantitative"},s,{allowDisabling:!1})}]}}else{(e.center||e.extent)&&yi((m=e.center,`${(p=e.extent)?"extent ":""}${p&&m?"and ":""}${m?"center ":""}${p&&m?"are ":"is "}not needed when data are aggregated.`)),"aggregated-upper-lower"===o?(f=[],c=[{calculate:`datum["${n.field}"]`,as:`upper_${u}`},{calculate:`datum["${u}"]`,as:`lower_${u}`}]):"aggregated-error"===o&&(f=[{fieldPrefix:"",titlePrefix:u}],c=[{calculate:`datum["${u}"] + datum["${i.field}"]`,as:`upper_${u}`}],r?c.push({calculate:`datum["${u}"] + datum["${r.field}"]`,as:`lower_${u}`}):c.push({calculate:`datum["${u}"] - datum["${i.field}"]`,as:`lower_${u}`}));for(const e of c)f.push({fieldPrefix:e.as.substring(0,6),titlePrefix:M(M(e.calculate,'datum["',""),'"]',"")})}var m,p;return{postAggregateCalculates:c,errorBarSpecificAggregate:l,tooltipSummary:f,tooltipTitleWithFieldName:d}}(l,f,d,m,p,u,t,n),{[g]:x,["x"===g?"x2":"y2"]:$,["x"===g?"xError":"yError"]:w,["x"===g?"xError2":"yError2"]:k,...S}=r,{bins:D,timeUnits:F,aggregate:z,groupby:O,encoding:_}=Ta(S,n),C=[...z,...h],N="raw"!==u?[]:O,A=Ua(v,f,_,b);return{transform:[...s.transform??[],...D,...F,...0===C.length?[]:[{aggregate:C,groupby:N}],...y],groupby:N,continuousAxisChannelDef:f,continuousAxis:g,encodingWithoutContinuousAxis:_,ticksOrient:"vertical"===c?"horizontal":"vertical",markDef:l,outerSpec:s,tooltipEncoding:A}}function is(e,t,n){return`${P(e)} ${n} ${t}`}const rs="errorband",os=new Ca(rs,as);function as(e,t){let{config:n}=t;e={...e,encoding:Ma(e.encoding,n)};const{transform:i,continuousAxisChannelDef:r,continuousAxis:o,encodingWithoutContinuousAxis:a,markDef:s,outerSpec:l,tooltipEncoding:c}=ns(e,rs,n),u=s,f=Wa(u,o,r,a,n.errorband),d=void 0!==e.encoding.x&&void 0!==e.encoding.y;let m={type:d?"area":"rect"},p={type:d?"line":"rule"};const g={...u.interpolate?{interpolate:u.interpolate}:{},...u.tension&&u.interpolate?{tension:u.tension}:{}};return d?(m={...m,...g,ariaRoleDescription:"errorband"},p={...p,...g,aria:!1}):u.interpolate?yi(mi("interpolate")):u.tension&&yi(mi("tension")),{...l,transform:i,layer:[...f({partName:"band",mark:m,positionPrefix:"lower",endPositionPrefix:"upper",extraEncoding:c}),...f({partName:"borders",mark:p,positionPrefix:"lower",extraEncoding:c}),...f({partName:"borders",mark:p,positionPrefix:"upper",extraEncoding:c})]}}const ss={};function ls(e,t,n){const i=new Ca(e,t);ss[e]={normalizer:i,parts:n}}ls(Ga,Qa,["box","median","outliers","rule","ticks"]),ls(Ka,es,["ticks","rule"]),ls(rs,as,["band","borders"]);const cs=["gradientHorizontalMaxLength","gradientHorizontalMinLength","gradientVerticalMaxLength","gradientVerticalMinLength","unselectedOpacity"],us={titleAlign:"align",titleAnchor:"anchor",titleAngle:"angle",titleBaseline:"baseline",titleColor:"color",titleFont:"font",titleFontSize:"fontSize",titleFontStyle:"fontStyle",titleFontWeight:"fontWeight",titleLimit:"limit",titleLineHeight:"lineHeight",titleOrient:"orient",titlePadding:"offset"},fs={labelAlign:"align",labelAnchor:"anchor",labelAngle:"angle",labelBaseline:"baseline",labelColor:"color",labelFont:"font",labelFontSize:"fontSize",labelFontStyle:"fontStyle",labelFontWeight:"fontWeight",labelLimit:"limit",labelLineHeight:"lineHeight",labelOrient:"orient",labelPadding:"offset"},ds=D(us),ms=D(fs),ps=D({header:1,headerRow:1,headerColumn:1,headerFacet:1}),gs=["size","shape","fill","stroke","strokeDash","strokeWidth","opacity"],hs="_vgsid_",ys={point:{on:"click",fields:[hs],toggle:"event.shiftKey",resolve:"global",clear:"dblclick"},interval:{on:"[pointerdown, window:pointerup] > window:pointermove!",encodings:["x","y"],translate:"[pointerdown, window:pointerup] > window:pointermove!",zoom:"wheel!",mark:{fill:"#333",fillOpacity:.125,stroke:"white"},resolve:"global",clear:"dblclick"}};function vs(e){return"legend"===e||!!e?.legend}function bs(e){return vs(e)&&t.isObject(e)}function xs(e){return!!e?.select}function $s(e){const t=[];for(const n of e||[]){if(xs(n))continue;const{expr:e,bind:i,...r}=n;if(i&&e){const n={...r,bind:i,init:e};t.push(n)}else{const n={...r,...e?{update:e}:{},...i?{bind:i}:{}};t.push(n)}}return t}function ws(e){return"concat"in e}function ks(e){return"vconcat"in e}function Ss(e){return"hconcat"in e}function Ds(e){let{step:t,offsetIsDiscrete:n}=e;return n?t.for??"offset":"position"}function Fs(e){return t.isObject(e)&&void 0!==e.step}function zs(e){return e.view||e.width||e.height}const Os=D({align:1,bounds:1,center:1,columns:1,spacing:1});function _s(e,t){return e[t]??e["width"===t?"continuousWidth":"continuousHeight"]}function Cs(e,t){const n=Ns(e,t);return Fs(n)?n.step:Ps}function Ns(e,t){return U(e[t]??e["width"===t?"discreteWidth":"discreteHeight"],{step:e.step})}const Ps=20,As={background:"white",padding:5,timeFormat:"%b %d, %Y",countTitle:"Count of Records",view:{continuousWidth:200,continuousHeight:200,step:Ps},mark:{color:"#4c78a8",invalid:"filter",timeUnitBandSize:1},arc:{},area:{},bar:$o,circle:{},geoshape:{},image:{},line:{},point:{},rect:wo,rule:{color:"black"},square:{},text:{color:"black"},tick:{thickness:1},trail:{},boxplot:{size:14,extent:1.5,box:{},median:{color:"white"},outliers:{},rule:{},ticks:null},errorbar:{center:"mean",rule:!0,ticks:!1},errorband:{band:{opacity:.3},borders:!1},scale:{pointPadding:.5,barBandPaddingInner:.1,rectBandPaddingInner:0,bandWithNestedOffsetPaddingInner:.2,bandWithNestedOffsetPaddingOuter:.2,minBandSize:2,minFontSize:8,maxFontSize:40,minOpacity:.3,maxOpacity:.8,minSize:9,minStrokeWidth:1,maxStrokeWidth:4,quantileCount:4,quantizeCount:4,zero:!0},projection:{},legend:{gradientHorizontalMaxLength:200,gradientHorizontalMinLength:100,gradientVerticalMaxLength:200,gradientVerticalMinLength:64,unselectedOpacity:.35},header:{titlePadding:10,labelPadding:10},headerColumn:{},headerRow:{},headerFacet:{},selection:ys,style:{},title:{},facet:{spacing:20},concat:{spacing:20},normalizedNumberFormat:".0%"},js=["#4c78a8","#f58518","#e45756","#72b7b2","#54a24b","#eeca3b","#b279a2","#ff9da6","#9d755d","#bab0ac"],Ts={text:11,guideLabel:10,guideTitle:11,groupTitle:13,groupSubtitle:12},Es={blue:js[0],orange:js[1],red:js[2],teal:js[3],green:js[4],yellow:js[5],purple:js[6],pink:js[7],brown:js[8],gray0:"#000",gray1:"#111",gray2:"#222",gray3:"#333",gray4:"#444",gray5:"#555",gray6:"#666",gray7:"#777",gray8:"#888",gray9:"#999",gray10:"#aaa",gray11:"#bbb",gray12:"#ccc",gray13:"#ddd",gray14:"#eee",gray15:"#fff"};function Ms(e){const t=D(e||{}),n={};for(const i of t){const t=e[i];n[i]=wa(t)?kn(t):Sn(t)}return n}const Ls=[...vo,...Oa,...ps,"background","padding","legend","lineBreak","scale","style","title","view"];function qs(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{color:n,font:i,fontSize:r,selection:o,...a}=e,s=t.mergeConfig({},l(As),i?function(e){return{text:{font:e},style:{"guide-label":{font:e},"guide-title":{font:e},"group-title":{font:e},"group-subtitle":{font:e}}}}(i):{},n?function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return{signals:[{name:"color",value:t.isObject(e)?{...Es,...e}:Es}],mark:{color:{signal:"color.blue"}},rule:{color:{signal:"color.gray0"}},text:{color:{signal:"color.gray0"}},style:{"guide-label":{fill:{signal:"color.gray0"}},"guide-title":{fill:{signal:"color.gray0"}},"group-title":{fill:{signal:"color.gray0"}},"group-subtitle":{fill:{signal:"color.gray0"}},cell:{stroke:{signal:"color.gray8"}}},axis:{domainColor:{signal:"color.gray13"},gridColor:{signal:"color.gray8"},tickColor:{signal:"color.gray13"}},range:{category:[{signal:"color.blue"},{signal:"color.orange"},{signal:"color.red"},{signal:"color.teal"},{signal:"color.green"},{signal:"color.yellow"},{signal:"color.purple"},{signal:"color.pink"},{signal:"color.brown"},{signal:"color.grey8"}]}}}(n):{},r?function(e){return{signals:[{name:"fontSize",value:t.isObject(e)?{...Ts,...e}:Ts}],text:{fontSize:{signal:"fontSize.text"}},style:{"guide-label":{fontSize:{signal:"fontSize.guideLabel"}},"guide-title":{fontSize:{signal:"fontSize.guideTitle"}},"group-title":{fontSize:{signal:"fontSize.groupTitle"}},"group-subtitle":{fontSize:{signal:"fontSize.groupSubtitle"}}}}}(r):{},a||{});o&&t.writeConfig(s,"selection",o,!0);const c=f(s,Ls);for(const e of["background","lineBreak","padding"])s[e]&&(c[e]=Sn(s[e]));for(const e of vo)s[e]&&(c[e]=pn(s[e]));for(const e of Oa)s[e]&&(c[e]=Ms(s[e]));for(const e of ps)s[e]&&(c[e]=pn(s[e]));return s.legend&&(c.legend=pn(s.legend)),s.scale&&(c.scale=pn(s.scale)),s.style&&(c.style=function(e){const t=D(e),n={};for(const i of t)n[i]=Ms(e[i]);return n}(s.style)),s.title&&(c.title=pn(s.title)),s.view&&(c.view=pn(s.view)),c}const Us=new Set(["view",...po]),Rs=["color","fontSize","background","padding","facet","concat","numberFormat","numberFormatType","normalizedNumberFormat","normalizedNumberFormatType","timeFormat","countTitle","header","axisQuantitative","axisTemporal","axisDiscrete","axisPoint","axisXBand","axisXPoint","axisXDiscrete","axisXQuantitative","axisXTemporal","axisYBand","axisYPoint","axisYDiscrete","axisYQuantitative","axisYTemporal","scale","selection","overlay"],Ws={view:["continuousWidth","continuousHeight","discreteWidth","discreteHeight","step"],area:["line","point"],bar:["binSpacing","continuousBandSize","discreteBandSize","minBandSize"],rect:["binSpacing","continuousBandSize","discreteBandSize","minBandSize"],line:["point"],tick:["bandSize","thickness"]};function Bs(e){e=l(e);for(const t of Rs)delete e[t];if(e.axis)for(const t in e.axis)wa(e.axis[t])&&delete e.axis[t];if(e.legend)for(const t of cs)delete e.legend[t];if(e.mark){for(const t of yo)delete e.mark[t];e.mark.tooltip&&t.isObject(e.mark.tooltip)&&delete e.mark.tooltip}e.params&&(e.signals=(e.signals||[]).concat($s(e.params)),delete e.params);for(const t of Us){for(const n of yo)delete e[t][n];const n=Ws[t];if(n)for(const i of n)delete e[t][i];Is(e,t)}for(const t of D(ss))delete e[t];!function(e){const{titleMarkConfig:t,subtitleMarkConfig:n,subtitle:i}=gn(e.title);S(t)||(e.style["group-title"]={...e.style["group-title"],...t});S(n)||(e.style["group-subtitle"]={...e.style["group-subtitle"],...n});S(i)?delete e.title:e.title=i}(e);for(const n in e)t.isObject(e[n])&&S(e[n])&&delete e[n];return S(e)?void 0:e}function Is(e,t,n,i){"view"===t&&(n="cell");const r={...e[t],...e.style[n??t]};S(r)||(e.style[n??t]=r),delete e[t]}function Hs(e){return"layer"in e}class Vs{map(e,t){return No(e)?this.mapFacet(e,t):function(e){return"repeat"in e}(e)?this.mapRepeat(e,t):Ss(e)?this.mapHConcat(e,t):ks(e)?this.mapVConcat(e,t):ws(e)?this.mapConcat(e,t):this.mapLayerOrUnit(e,t)}mapLayerOrUnit(e,t){if(Hs(e))return this.mapLayer(e,t);if(_a(e))return this.mapUnit(e,t);throw new Error(qn(e))}mapLayer(e,t){return{...e,layer:e.layer.map((e=>this.mapLayerOrUnit(e,t)))}}mapHConcat(e,t){return{...e,hconcat:e.hconcat.map((e=>this.map(e,t)))}}mapVConcat(e,t){return{...e,vconcat:e.vconcat.map((e=>this.map(e,t)))}}mapConcat(e,t){const{concat:n,...i}=e;return{...i,concat:n.map((e=>this.map(e,t)))}}mapFacet(e,t){return{...e,spec:this.map(e.spec,t)}}mapRepeat(e,t){return{...e,spec:this.map(e.spec,t)}}}const Gs={zero:1,center:1,normalize:1};const Ys=new Set([Jr,Zr,Kr,ro,no,lo,co,to,oo,ao]),Xs=new Set([Zr,Kr,Jr]);function Qs(e){return Ro(e)&&"quantitative"===Wo(e)&&!e.bin}function Js(e,t,n){let{orient:i,type:r}=n;const o="x"===t?"y":"radius",a="x"===t&&["bar","area"].includes(r),s=e[t],l=e[o];if(Ro(s)&&Ro(l))if(Qs(s)&&Qs(l)){if(s.stack)return t;if(l.stack)return o;const e=Ro(s)&&!!s.aggregate;if(e!==(Ro(l)&&!!l.aggregate))return e?t:o;if(a){if("vertical"===i)return o;if("horizontal"===i)return t}}else{if(Qs(s))return t;if(Qs(l))return o}else{if(Qs(s)){if(a&&"vertical"===i)return;return t}if(Qs(l)){if(a&&"horizontal"===i)return;return o}}}function Ks(e,n){const i=go(e)?e:{type:e},r=i.type;if(!Ys.has(r))return null;const o=Js(n,"x",i)||Js(n,"theta",i);if(!o)return null;const a=n[o],s=Ro(a)?ta(a,{}):void 0,l=function(e){switch(e){case"x":return"y";case"y":return"x";case"theta":return"radius";case"radius":return"theta"}}(o),c=[],u=new Set;if(n[l]){const e=n[l],t=Ro(e)?ta(e,{}):void 0;t&&t!==s&&(c.push(l),u.add(t))}const f="x"===l?"xOffset":"yOffset",d=n[f],m=Ro(d)?ta(d,{}):void 0;m&&m!==s&&(c.push(f),u.add(m));const p=St.reduce(((e,i)=>{if("tooltip"!==i&&Na(n,i)){const r=n[i];for(const n of t.array(r)){const t=ua(n);if(t.aggregate)continue;const r=ta(t,{});r&&u.has(r)||e.push({channel:i,fieldDef:t})}}return e}),[]);let g;return void 0!==a.stack?g=t.isBoolean(a.stack)?a.stack?"zero":null:a.stack:Xs.has(r)&&(g="zero"),g&&g in Gs?ja(n)&&0===p.length?null:(a?.scale?.type&&a?.scale?.type!==or.LINEAR&&a?.stack&&yi(function(e){return`Stack is applied to a non-linear scale (${e}).`}(a.scale.type)),Go(n[it(o)])?(void 0!==a.stack&&yi(`Cannot stack "${h=o}" if there is already "${h}2".`),null):(Ro(a)&&a.aggregate&&!on.has(a.aggregate)&&yi(`Stacking is applied even though the aggregate function is non-summative ("${a.aggregate}").`),{groupbyChannels:c,groupbyFields:u,fieldChannel:o,impute:null!==a.impute&&fo(r),stackBy:p,offset:g})):null;var h}function Zs(e,t,n){const i=pn(e),r=Nn("orient",i,n);if(i.orient=function(e,t,n){switch(e){case no:case lo:case co:case oo:case io:case eo:return}const{x:i,y:r,x2:o,y2:a}=t;switch(e){case Zr:if(Ro(i)&&(cn(i.bin)||Ro(r)&&r.aggregate&&!i.aggregate))return"vertical";if(Ro(r)&&(cn(r.bin)||Ro(i)&&i.aggregate&&!r.aggregate))return"horizontal";if(a||o){if(n)return n;if(!o)return(Ro(i)&&i.type===er&&!ln(i.bin)||Vo(i))&&Ro(r)&&cn(r.bin)?"horizontal":"vertical";if(!a)return(Ro(r)&&r.type===er&&!ln(r.bin)||Vo(r))&&Ro(i)&&cn(i.bin)?"vertical":"horizontal"}case ro:if(o&&(!Ro(i)||!cn(i.bin))&&a&&(!Ro(r)||!cn(r.bin)))return;case Kr:if(a)return Ro(r)&&cn(r.bin)?"horizontal":"vertical";if(o)return Ro(i)&&cn(i.bin)?"vertical":"horizontal";if(e===ro){if(i&&!r)return"vertical";if(r&&!i)return"horizontal"}case to:case ao:{const t=Ho(i),o=Ho(r);if(n)return n;if(t&&!o)return"tick"!==e?"horizontal":"vertical";if(!t&&o)return"tick"!==e?"vertical":"horizontal";if(t&&o)return"vertical";{const e=Yo(i)&&i.type===nr,t=Yo(r)&&r.type===nr;if(e&&!t)return"vertical";if(!e&&t)return"horizontal"}return}}return"vertical"}(i.type,t,r),void 0!==r&&r!==i.orient&&yi(`Specified orient "${i.orient}" overridden with "${r}".`),"bar"===i.type&&i.orient){const e=Nn("cornerRadiusEnd",i,n);if(void 0!==e){const n="horizontal"===i.orient&&t.x2||"vertical"===i.orient&&t.y2?["cornerRadius"]:xo[i.orient];for(const t of n)i[t]=e;void 0!==i.cornerRadiusEnd&&delete i.cornerRadiusEnd}}const o=Nn("opacity",i,n),a=Nn("fillOpacity",i,n);void 0===o&&void 0===a&&(i.opacity=function(e,t){if(p([no,ao,lo,co],e)&&!ja(t))return.7;return}(i.type,t));return void 0===Nn("cursor",i,n)&&(i.cursor=function(e,t,n){if(t.href||e.href||Nn("href",e,n))return"pointer";return e.cursor}(i,t,n)),i}function el(e){const{point:t,line:n,...i}=e;return D(i).length>1?i:i.type}function tl(e){for(const t of["line","area","rule","trail"])e[t]&&(e={...e,[t]:f(e[t],["point","line"])});return e}function nl(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2?arguments[2]:void 0;return"transparent"===e.point?{opacity:0}:e.point?t.isObject(e.point)?e.point:{}:void 0!==e.point?null:n.point||i.shape?t.isObject(n.point)?n.point:{}:void 0}function il(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return e.line?!0===e.line?{}:e.line:void 0!==e.line?null:t.line?!0===t.line?{}:t.line:void 0}class rl{name="path-overlay";hasMatchingType(e,t){if(_a(e)){const{mark:n,encoding:i}=e,r=go(n)?n:{type:n};switch(r.type){case"line":case"rule":case"trail":return!!nl(r,t[r.type],i);case"area":return!!nl(r,t[r.type],i)||!!il(r,t[r.type])}}return!1}run(e,t,n){const{config:i}=t,{params:r,projection:o,mark:a,name:s,encoding:l,...c}=e,d=Ma(l,i),m=go(a)?a:{type:a},p=nl(m,i[m.type],d),g="area"===m.type&&il(m,i[m.type]),h=[{name:s,...r?{params:r}:{},mark:el({..."area"===m.type&&void 0===m.opacity&&void 0===m.fillOpacity?{opacity:.7}:{},...m}),encoding:f(d,["shape"])}],y=Ks(Zs(m,d,i),d);let v=d;if(y){const{fieldChannel:e,offset:t}=y;v={...d,[e]:{...d[e],...t?{stack:t}:{}}}}return v=f(v,["y2","x2"]),g&&h.push({...o?{projection:o}:{},mark:{type:"line",...u(m,["clip","interpolate","tension","tooltip"]),...g},encoding:v}),p&&h.push({...o?{projection:o}:{},mark:{type:"point",opacity:1,filled:!0,...u(m,["clip","tooltip"]),...p},encoding:v}),n({...c,layer:h},{...t,config:tl(i)})}}function ol(e,t){return t?_o(e)?fl(e,t):ll(e,t):e}function al(e,t){return t?fl(e,t):e}function sl(e,n,i){const r=n[e];return(o=r)&&!t.isString(o)&&"repeat"in o?r.repeat in i?{...n,[e]:i[r.repeat]}:void yi(function(e){return`Unknown repeated value "${e}".`}(r.repeat)):n;var o}function ll(e,t){if(void 0!==(e=sl("field",e,t))){if(null===e)return null;if(Ao(e)&&zo(e.sort)){const n=sl("field",e.sort,t);e={...e,...n?{sort:n}:{}}}return e}}function cl(e,t){if(Ro(e))return ll(e,t);{const n=sl("datum",e,t);return n===e||n.type||(n.type="nominal"),n}}function ul(e,t){if(!Go(e)){if(Uo(e)){const n=cl(e.condition,t);if(n)return{...e,condition:n};{const{condition:t,...n}=e;return n}}return e}{const n=cl(e,t);if(n)return n;if(Lo(e))return{condition:e.condition}}}function fl(e,n){const i={};for(const r in e)if(t.hasOwnProperty(e,r)){const o=e[r];if(t.isArray(o))i[r]=o.map((e=>ul(e,n))).filter((e=>e));else{const e=ul(o,n);void 0!==e&&(i[r]=e)}}return i}class dl{name="RuleForRangedLine";hasMatchingType(e){if(_a(e)){const{encoding:t,mark:n}=e;if("line"===n||go(n)&&"line"===n.type)for(const e of Ze){const n=t[tt(e)];if(t[e]&&(Ro(n)&&!cn(n.bin)||Bo(n)))return!0}}return!1}run(e,n,i){const{encoding:r,mark:o}=e;var a,s;return yi((a=!!r.x2,s=!!r.y2,`Line mark is for continuous lines and thus cannot be used with ${a&&s?"x2 and y2":a?"x2":"y2"}. We will use the rule mark (line segments) instead.`)),i({...e,mark:t.isObject(o)?{...o,type:"rule"}:"rule"},n)}}function ml(e){let{parentEncoding:n,encoding:i={},layer:r}=e,o={};if(n){const e=new Set([...D(n),...D(i)]);for(const a of e){const e=i[a],s=n[a];if(Go(e)){const t={...s,...e};o[a]=t}else Uo(e)?o[a]={...e,condition:{...s,...e.condition}}:e||null===e?o[a]=e:(r||Xo(s)||yn(s)||Go(s)||t.isArray(s))&&(o[a]=s)}}else o=i;return!o||S(o)?void 0:o}function pl(e){const{parentProjection:t,projection:n}=e;return t&&n&&yi(function(e){const{parentProjection:t,projection:n}=e;return`Layer's shared projection ${X(t)} is overridden by a child projection ${X(n)}.`}({parentProjection:t,projection:n})),n??t}function gl(e){return"filter"in e}function hl(e){return"lookup"in e}function yl(e){return"pivot"in e}function vl(e){return"density"in e}function bl(e){return"quantile"in e}function xl(e){return"regression"in e}function $l(e){return"loess"in e}function wl(e){return"sample"in e}function kl(e){return"window"in e}function Sl(e){return"joinaggregate"in e}function Dl(e){return"flatten"in e}function Fl(e){return"calculate"in e}function zl(e){return"bin"in e}function Ol(e){return"impute"in e}function _l(e){return"timeUnit"in e}function Cl(e){return"aggregate"in e}function Nl(e){return"stack"in e}function Pl(e){return"fold"in e}function Al(e){return"extent"in e&&!("density"in e)&&!("regression"in e)}function jl(e,t){const{transform:n,...i}=e;if(n){return{...i,transform:n.map((e=>{if(gl(e))return{filter:Ml(e,t)};if(zl(e)&&un(e.bin))return{...e,bin:El(e.bin)};if(hl(e)){const{selection:t,...n}=e.from;return t?{...e,from:{param:t,...n}}:e}return e}))}}return e}function Tl(e,n){const i=l(e);if(Ro(i)&&un(i.bin)&&(i.bin=El(i.bin)),Qo(i)&&i.scale?.domain?.selection){const{selection:e,...t}=i.scale.domain;i.scale.domain={...t,...e?{param:e}:{}}}if(Lo(i))if(t.isArray(i.condition))i.condition=i.condition.map((e=>{const{selection:t,param:i,test:r,...o}=e;return i?e:{...o,test:Ml(e,n)}}));else{const{selection:e,param:t,test:r,...o}=Tl(i.condition,n);i.condition=t?i.condition:{...o,test:Ml(i.condition,n)}}return i}function El(e){const t=e.extent;if(t?.selection){const{selection:n,...i}=t;return{...e,extent:{...i,param:n}}}return e}function Ml(e,t){const n=e=>s(e,(e=>{const n={param:e,empty:t.emptySelections[e]??!0};return t.selectionPredicates[e]??=[],t.selectionPredicates[e].push(n),n}));return e.selection?n(e.selection):s(e.test||e.filter,(e=>e.selection?n(e.selection):e))}class Ll extends Vs{map(e,t){const n=t.selections??[];if(e.params&&!_a(e)){const t=[];for(const i of e.params)xs(i)?n.push(i):t.push(i);e.params=t}return t.selections=n,super.map(e,t)}mapUnit(e,n){const i=n.selections;if(!i||!i.length)return e;const r=(n.path??[]).concat(e.name),o=[];for(const n of i)if(n.views&&n.views.length)for(const i of n.views)(t.isString(i)&&(i===e.name||r.includes(i))||t.isArray(i)&&i.map((e=>r.indexOf(e))).every(((e,t,n)=>-1!==e&&(0===t||e>n[t-1]))))&&o.push(n);else o.push(n);return o.length&&(e.params=o),e}}for(const e of["mapFacet","mapRepeat","mapHConcat","mapVConcat","mapLayer"]){const t=Ll.prototype[e];Ll.prototype[e]=function(e,n){return t.call(this,e,ql(e,n))}}function ql(e,t){return e.name?{...t,path:(t.path??[]).concat(e.name)}:t}function Ul(e,t){void 0===t&&(t=qs(e.config));const n=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const n={config:t};return Bl.map(Rl.map(Wl.map(e,n),n),n)}(e,t),{width:i,height:r}=e,o=function(e,t,n){let{width:i,height:r}=t;const o=_a(e)||Hs(e),a={};o?"container"==i&&"container"==r?(a.type="fit",a.contains="padding"):"container"==i?(a.type="fit-x",a.contains="padding"):"container"==r&&(a.type="fit-y",a.contains="padding"):("container"==i&&(yi(Rn("width")),i=void 0),"container"==r&&(yi(Rn("height")),r=void 0));const s={type:"pad",...a,...n?Il(n.autosize):{},...Il(e.autosize)};"fit"!==s.type||o||(yi(Un),s.type="pad");"container"==i&&"fit"!=s.type&&"fit-x"!=s.type&&yi(Wn("width"));"container"==r&&"fit"!=s.type&&"fit-y"!=s.type&&yi(Wn("height"));if(Y(s,{type:"pad"}))return;return s}(n,{width:i,height:r,autosize:e.autosize},t);return{...n,...o?{autosize:o}:{}}}const Rl=new class extends Vs{nonFacetUnitNormalizers=[Ya,Za,os,new rl,new dl];map(e,t){if(_a(e)){const n=Na(e.encoding,Q),i=Na(e.encoding,J),r=Na(e.encoding,K);if(n||i||r)return this.mapFacetedUnit(e,t)}return super.map(e,t)}mapUnit(e,t){const{parentEncoding:n,parentProjection:i}=t,r=al(e.encoding,t.repeater),o={...e,...e.name?{name:[t.repeaterPrefix,e.name].filter((e=>e)).join("_")}:{},...r?{encoding:r}:{}};if(n||i)return this.mapUnitWithParentEncodingOrProjection(o,t);const a=this.mapLayerOrUnit.bind(this);for(const e of this.nonFacetUnitNormalizers)if(e.hasMatchingType(o,t.config))return e.run(o,t,a);return o}mapRepeat(e,n){return function(e){return!t.isArray(e.repeat)&&e.repeat.layer}(e)?this.mapLayerRepeat(e,n):this.mapNonLayerRepeat(e,n)}mapLayerRepeat(e,t){const{repeat:n,spec:i,...r}=e,{row:o,column:a,layer:s}=n,{repeater:l={},repeaterPrefix:c=""}=t;return o||a?this.mapRepeat({...e,repeat:{...o?{row:o}:{},...a?{column:a}:{}},spec:{repeat:{layer:s},spec:i}},t):{...r,layer:s.map((e=>{const n={...l,layer:e},r=`${(i.name?`${i.name}_`:"")+c}child__layer_${_(e)}`,o=this.mapLayerOrUnit(i,{...t,repeater:n,repeaterPrefix:r});return o.name=r,o}))}}mapNonLayerRepeat(e,n){const{repeat:i,spec:r,data:o,...a}=e;!t.isArray(i)&&e.columns&&(e=f(e,["columns"]),yi(Xn("repeat")));const s=[],{repeater:l={},repeaterPrefix:c=""}=n,u=!t.isArray(i)&&i.row||[l?l.row:null],d=!t.isArray(i)&&i.column||[l?l.column:null],m=t.isArray(i)&&i||[l?l.repeat:null];for(const e of m)for(const o of u)for(const a of d){const u={repeat:e,row:o,column:a,layer:l.layer},d=(r.name?`${r.name}_`:"")+c+"child__"+(t.isArray(i)?`${_(e)}`:(i.row?`row_${_(o)}`:"")+(i.column?`column_${_(a)}`:"")),m=this.map(r,{...n,repeater:u,repeaterPrefix:d});m.name=d,s.push(f(m,["data"]))}const p=t.isArray(i)?e.columns:i.column?i.column.length:1;return{data:r.data??o,align:"all",...a,columns:p,concat:s}}mapFacet(e,t){const{facet:n}=e;return _o(n)&&e.columns&&(e=f(e,["columns"]),yi(Xn("facet"))),super.mapFacet(e,t)}mapUnitWithParentEncodingOrProjection(e,t){const{encoding:n,projection:i}=e,{parentEncoding:r,parentProjection:o,config:a}=t,s=pl({parentProjection:o,projection:i}),l=ml({parentEncoding:r,encoding:al(n,t.repeater)});return this.mapUnit({...e,...s?{projection:s}:{},...l?{encoding:l}:{}},{config:a})}mapFacetedUnit(e,t){const{row:n,column:i,facet:r,...o}=e.encoding,{mark:a,width:s,projection:l,height:c,view:u,params:f,encoding:d,...m}=e,{facetMapping:p,layout:g}=this.getFacetMappingAndLayout({row:n,column:i,facet:r},t),h=al(o,t.repeater);return this.mapFacet({...m,...g,facet:p,spec:{...s?{width:s}:{},...c?{height:c}:{},...u?{view:u}:{},...l?{projection:l}:{},mark:a,encoding:h,...f?{params:f}:{}}},t)}getFacetMappingAndLayout(e,t){const{row:n,column:i,facet:r}=e;if(n||i){r&&yi(`Facet encoding dropped as ${(o=[...n?[Q]:[],...i?[J]:[]]).join(" and ")} ${o.length>1?"are":"is"} also specified.`);const t={},a={};for(const n of[Q,J]){const i=e[n];if(i){const{align:e,center:r,spacing:o,columns:s,...l}=i;t[n]=l;for(const e of["align","center","spacing"])void 0!==i[e]&&(a[e]??={},a[e][n]=i[e])}}return{facetMapping:t,layout:a}}{const{align:e,center:n,spacing:i,columns:o,...a}=r;return{facetMapping:ol(a,t.repeater),layout:{...e?{align:e}:{},...n?{center:n}:{},...i?{spacing:i}:{},...o?{columns:o}:{}}}}var o}mapLayer(e,t){let{parentEncoding:n,parentProjection:i,...r}=t;const{encoding:o,projection:a,...s}=e,l={...r,parentEncoding:ml({parentEncoding:n,encoding:o,layer:!0}),parentProjection:pl({parentProjection:i,projection:a})};return super.mapLayer({...s,...e.name?{name:[l.repeaterPrefix,e.name].filter((e=>e)).join("_")}:{}},l)}},Wl=new class extends Vs{map(e,t){return t.emptySelections??={},t.selectionPredicates??={},e=jl(e,t),super.map(e,t)}mapLayerOrUnit(e,t){if((e=jl(e,t)).encoding){const n={};for(const[i,r]of z(e.encoding))n[i]=Tl(r,t);e={...e,encoding:n}}return super.mapLayerOrUnit(e,t)}mapUnit(e,t){const{selection:n,...i}=e;return n?{...i,params:z(n).map((e=>{let[n,i]=e;const{init:r,bind:o,empty:a,...s}=i;"single"===s.type?(s.type="point",s.toggle=!1):"multi"===s.type&&(s.type="point"),t.emptySelections[n]="none"!==a;for(const e of F(t.selectionPredicates[n]??{}))e.empty="none"!==a;return{name:n,value:r,select:s,bind:o}}))}:e}},Bl=new Ll;function Il(e){return t.isString(e)?{type:e}:e??{}}const Hl=["background","padding"];function Vl(e,t){const n={};for(const t of Hl)e&&void 0!==e[t]&&(n[t]=Sn(e[t]));return t&&(n.params=e.params),n}class Gl{constructor(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};this.explicit=e,this.implicit=t}clone(){return new Gl(l(this.explicit),l(this.implicit))}combine(){return{...this.explicit,...this.implicit}}get(e){return U(this.explicit[e],this.implicit[e])}getWithExplicit(e){return void 0!==this.explicit[e]?{explicit:!0,value:this.explicit[e]}:void 0!==this.implicit[e]?{explicit:!1,value:this.implicit[e]}:{explicit:!1,value:void 0}}setWithExplicit(e,t){let{value:n,explicit:i}=t;void 0!==n&&this.set(e,n,i)}set(e,t,n){return delete this[n?"implicit":"explicit"][e],this[n?"explicit":"implicit"][e]=t,this}copyKeyFromSplit(e,t){let{explicit:n,implicit:i}=t;void 0!==n[e]?this.set(e,n[e],!0):void 0!==i[e]&&this.set(e,i[e],!1)}copyKeyFromObject(e,t){void 0!==t[e]&&this.set(e,t[e],!0)}copyAll(e){for(const t of D(e.combine())){const n=e.getWithExplicit(t);this.setWithExplicit(t,n)}}}function Yl(e){return{explicit:!0,value:e}}function Xl(e){return{explicit:!1,value:e}}function Ql(e){return(t,n,i,r)=>{const o=e(t.value,n.value);return o>0?t:o<0?n:Jl(t,n,i,r)}}function Jl(e,t,n,i){return e.explicit&&t.explicit&&yi(function(e,t,n,i){return`Conflicting ${t.toString()} property "${e.toString()}" (${X(n)} and ${X(i)}). Using ${X(n)}.`}(n,i,e.value,t.value)),e}function Kl(e,t,n,i){let r=arguments.length>4&&void 0!==arguments[4]?arguments[4]:Jl;return void 0===e||void 0===e.value?t:e.explicit&&!t.explicit?e:t.explicit&&!e.explicit?t:Y(e.value,t.value)?e:r(e,t,n,i)}class Zl extends Gl{constructor(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=arguments.length>2&&void 0!==arguments[2]&&arguments[2];super(e,t),this.explicit=e,this.implicit=t,this.parseNothing=n}clone(){const e=super.clone();return e.parseNothing=this.parseNothing,e}}function ec(e){return"url"in e}function tc(e){return"values"in e}function nc(e){return"name"in e&&!ec(e)&&!tc(e)&&!ic(e)}function ic(e){return e&&(rc(e)||oc(e)||ac(e))}function rc(e){return"sequence"in e}function oc(e){return"sphere"in e}function ac(e){return"graticule"in e}let sc=function(e){return e[e.Raw=0]="Raw",e[e.Main=1]="Main",e[e.Row=2]="Row",e[e.Column=3]="Column",e[e.Lookup=4]="Lookup",e}({});function lc(e){const{signals:t,hasLegend:n,index:i,...r}=e;return r.field=E(r.field),r}function cc(e){let n=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:t.identity;if(t.isArray(e)){const t=e.map((e=>cc(e,n,i)));return n?`[${t.join(", ")}]`:t}return vi(e)?i(n?Si(e):function(e){const t=ki(e,!0);return e.utc?+new Date(Date.UTC(...t)):+new Date(...t)}(e)):n?i(X(e)):e}function uc(e,n){for(const i of F(e.component.selection??{})){const r=i.name;let o=`${r}${_u}, ${"global"===i.resolve?"true":`{unit: ${Au(e)}}`}`;for(const t of Pu)t.defined(i)&&(t.signals&&(n=t.signals(e,i,n)),t.modifyExpr&&(o=t.modifyExpr(e,i,o)));n.push({name:r+Cu,on:[{events:{signal:i.name+_u},update:`modify(${t.stringValue(i.name+Ou)}, ${o})`}]})}return mc(n)}function fc(e,n){if(e.component.selection&&D(e.component.selection).length){const i=t.stringValue(e.getName("cell"));n.unshift({name:"facet",value:{},on:[{events:t.parseSelector("pointermove","scope"),update:`isTuple(facet) ? facet : group(${i}).datum`}]})}return mc(n)}function dc(e,t){for(const n of F(e.component.selection??{}))for(const i of Pu)i.defined(n)&&i.marks&&(t=i.marks(e,n,t));return t}function mc(e){return e.map((e=>(e.on&&!e.on.length&&delete e.on,e)))}class pc{_children=[];_parent=null;constructor(e,t){this.debugName=t,e&&(this.parent=e)}clone(){throw new Error("Cannot clone node")}get parent(){return this._parent}set parent(e){this._parent=e,e&&e.addChild(this)}get children(){return this._children}numChildren(){return this._children.length}addChild(e,t){this._children.includes(e)?yi("Attempt to add the same child twice."):void 0!==t?this._children.splice(t,0,e):this._children.push(e)}removeChild(e){const t=this._children.indexOf(e);return this._children.splice(t,1),t}remove(){let e=this._parent.removeChild(this);for(const t of this._children)t._parent=this._parent,this._parent.addChild(t,e++)}insertAsParentOf(e){const t=e.parent;t.removeChild(this),this.parent=t,e.parent=this}swapWithParent(){const e=this._parent,t=e.parent;for(const t of this._children)t.parent=e;this._children=[],e.removeChild(this);const n=e.parent.removeChild(e);this._parent=t,t.addChild(this,n),e.parent=this}}class gc extends pc{clone(){const e=new this.constructor;return e.debugName=`clone_${this.debugName}`,e._source=this._source,e._name=`clone_${this._name}`,e.type=this.type,e.refCounts=this.refCounts,e.refCounts[e._name]=0,e}constructor(e,t,n,i){super(e,t),this.type=n,this.refCounts=i,this._source=this._name=t,this.refCounts&&!(this._name in this.refCounts)&&(this.refCounts[this._name]=0)}dependentFields(){return new Set}producedFields(){return new Set}hash(){return void 0===this._hash&&(this._hash=`Output ${W()}`),this._hash}getSource(){return this.refCounts[this._name]++,this._source}isRequired(){return!!this.refCounts[this._name]}setSource(e){this._source=e}}function hc(e){return void 0!==e.as}function yc(e){return`${e}_end`}class vc extends pc{clone(){return new vc(null,l(this.timeUnits))}constructor(e,t){super(e),this.timeUnits=t}static makeFromEncoding(e,t){const n=t.reduceFieldDef(((e,n,i)=>{const{field:r,timeUnit:o}=n;if(o){let a;if(zi(o)){if(gm(t)){const{mark:e,markDef:i,config:s}=t,l=jo({fieldDef:n,markDef:i,config:s});(mo(e)||l)&&(a={timeUnit:Ei(o),field:r})}}else a={as:ta(n,{forAs:!0}),field:r,timeUnit:o};if(gm(t)){const{mark:e,markDef:r,config:o}=t,s=jo({fieldDef:n,markDef:r,config:o});mo(e)&&zt(i)&&.5!==s&&(a.rectBandPosition=s)}a&&(e[d(a)]=a)}return e}),{});return S(n)?null:new vc(e,n)}static makeFromTransform(e,t){const{timeUnit:n,...i}={...t},r={...i,timeUnit:Ei(n)};return new vc(e,{[d(r)]:r})}merge(e){this.timeUnits={...this.timeUnits};for(const t in e.timeUnits)this.timeUnits[t]||(this.timeUnits[t]=e.timeUnits[t]);for(const t of e.children)e.removeChild(t),t.parent=this;e.remove()}removeFormulas(e){const t={};for(const[n,i]of z(this.timeUnits)){const r=hc(i)?i.as:`${i.field}_end`;e.has(r)||(t[n]=i)}this.timeUnits=t}producedFields(){return new Set(F(this.timeUnits).map((e=>hc(e)?e.as:yc(e.field))))}dependentFields(){return new Set(F(this.timeUnits).map((e=>e.field)))}hash(){return`TimeUnit ${d(this.timeUnits)}`}assemble(){const e=[];for(const t of F(this.timeUnits)){const{rectBandPosition:n}=t,i=Ei(t.timeUnit);if(hc(t)){const{field:r,as:o}=t,{unit:a,utc:s,...l}=i,c=[o,`${o}_end`];e.push({field:E(r),type:"timeunit",...a?{units:Ni(a)}:{},...s?{timezone:"utc"}:{},...l,as:c}),e.push(...wc(c,n,i))}else if(t){const{field:r}=t,o=r.replaceAll("\\.","."),a=$c({timeUnit:i,field:o}),s=yc(o);e.push({type:"formula",expr:a,as:s}),e.push(...wc([o,s],n,i))}}return e}}const bc="offsetted_rect_start",xc="offsetted_rect_end";function $c(e){let{timeUnit:t,field:n,reverse:i}=e;const{unit:r,utc:o}=t,a=Pi(r),{part:s,step:l}=qi(a,t.step);return`${o?"utcOffset":"timeOffset"}('${s}', datum['${n}'], ${i?-l:l})`}function wc(e,t,n){let[i,r]=e;if(void 0!==t&&.5!==t){const e=`datum['${i}']`,o=`datum['${r}']`;return[{type:"formula",expr:kc([$c({timeUnit:n,field:i,reverse:!0}),e],t+.5),as:`${i}_${bc}`},{type:"formula",expr:kc([e,o],t+.5),as:`${i}_${xc}`}]}return[]}function kc(e,t){let[n,i]=e;return`${1-t} * ${n} + ${t} * ${i}`}const Sc="_tuple_fields";class Dc{constructor(){for(var e=arguments.length,t=new Array(e),n=0;n!0,parse:(e,n,i)=>{const r=n.name,o=n.project??=new Dc,a={},s={},l=new Set,c=(e,t)=>{const n="visual"===t?e.channel:e.field;let i=_(`${r}_${n}`);for(let e=1;l.has(i);e++)i=_(`${r}_${n}_${e}`);return l.add(i),{[t]:i}},u=n.type,f=e.config.selection[u],m=void 0!==i.value?t.array(i.value):null;let{fields:p,encodings:g}=t.isObject(i.select)?i.select:{};if(!p&&!g&&m)for(const e of m)if(t.isObject(e))for(const t of D(e))Je[t]?(g||(g=[])).push(t):"interval"===u?(yi('Interval selections should be initialized using "x", "y", "longitude", or "latitude" keys.'),g=f.encodings):(p??=[]).push(t);p||g||(g=f.encodings,"fields"in f&&(p=f.fields));for(const t of g??[]){const n=e.fieldDef(t);if(n){let i=n.field;if(n.aggregate){yi(Vn(t,n.aggregate));continue}if(!i){yi(Hn(t));continue}if(n.timeUnit&&!zi(n.timeUnit)){i=e.vgField(t);const r={timeUnit:n.timeUnit,as:i,field:n.field};s[d(r)]=r}if(!a[i]){const r={field:i,channel:t,type:"interval"===u&&Ht(t)&&yr(e.getScaleComponent(t).get("type"))?"R":n.bin?"R-RE":"E",index:o.items.length};r.signals={...c(r,"data"),...c(r,"visual")},o.items.push(a[i]=r),o.hasField[i]=a[i],o.hasSelectionId=o.hasSelectionId||i===hs,Ee(t)?(r.geoChannel=t,r.channel=Te(t),o.hasChannel[r.channel]=a[i]):o.hasChannel[t]=a[i]}}else yi(Hn(t))}for(const e of p??[]){if(o.hasField[e])continue;const t={type:"E",field:e,index:o.items.length};t.signals={...c(t,"data")},o.items.push(t),o.hasField[e]=t,o.hasSelectionId=o.hasSelectionId||e===hs}m&&(n.init=m.map((e=>o.items.map((n=>t.isObject(e)?void 0!==e[n.geoChannel||n.channel]?e[n.geoChannel||n.channel]:e[n.field]:e))))),S(s)||(o.timeUnit=new vc(null,s))},signals:(e,t,n)=>{const i=t.name+Sc;return n.filter((e=>e.name===i)).length>0||t.project.hasSelectionId?n:n.concat({name:i,value:t.project.items.map(lc)})}},zc={defined:e=>"interval"===e.type&&"global"===e.resolve&&e.bind&&"scales"===e.bind,parse:(e,t)=>{const n=t.scales=[];for(const i of t.project.items){const r=i.channel;if(!Ht(r))continue;const o=e.getScaleComponent(r),a=o?o.get("type"):void 0;"sequential"==a&&yi("Sequntial scales are deprecated. The available quantitative scale type values are linear, log, pow, sqrt, symlog, time and utc"),o&&yr(a)?(o.set("selectionExtent",{param:t.name,field:i.field},!0),n.push(i)):yi("Scale bindings are currently only supported for scales with unbinned, continuous domains.")}},topLevelSignals:(e,n,i)=>{const r=n.scales.filter((e=>0===i.filter((t=>t.name===e.signals.data)).length));if(!e.parent||_c(e)||0===r.length)return i;const o=i.filter((e=>e.name===n.name))[0];let a=o.update;if(a.indexOf(Nu)>=0)o.update=`{${r.map((e=>`${t.stringValue(E(e.field))}: ${e.signals.data}`)).join(", ")}}`;else{for(const e of r){const n=`${t.stringValue(E(e.field))}: ${e.signals.data}`;a.includes(n)||(a=`${a.substring(0,a.length-1)}, ${n}}`)}o.update=a}return i.concat(r.map((e=>({name:e.signals.data}))))},signals:(e,t,n)=>{if(e.parent&&!_c(e))for(const e of t.scales){const t=n.find((t=>t.name===e.signals.data));t.push="outer",delete t.value,delete t.update}return n}};function Oc(e,n){return`domain(${t.stringValue(e.scaleName(n))})`}function _c(e){return e.parent&&vm(e.parent)&&(!e.parent.parent??_c(e.parent.parent))}const Cc="_brush",Nc="_scale_trigger",Pc="geo_interval_init_tick",Ac="_init",jc={defined:e=>"interval"===e.type,parse:(e,n,i)=>{if(e.hasProjection){const e={...t.isObject(i.select)?i.select:{}};e.fields=[hs],e.encodings||(e.encodings=i.value?D(i.value):[ue,ce]),i.select={type:"interval",...e}}if(n.translate&&!zc.defined(n)){const e=`!event.item || event.item.mark.name !== ${t.stringValue(n.name+Cc)}`;for(const i of n.events){if(!i.between){yi(`${i} is not an ordered event stream for interval selections.`);continue}const n=t.array(i.between[0].filter??=[]);n.indexOf(e)<0&&n.push(e)}}},signals:(e,n,i)=>{const r=n.name,o=r+_u,a=F(n.project.hasChannel).filter((e=>e.channel===Z||e.channel===ee)),s=n.init?n.init[0]:null;if(i.push(...a.reduce(((i,r)=>i.concat(function(e,n,i,r){const o=!e.hasProjection,a=i.channel,s=i.signals.visual,l=t.stringValue(o?e.scaleName(a):e.projectionName()),c=e=>`scale(${l}, ${e})`,u=e.getSizeSignalRef(a===Z?"width":"height").signal,f=`${a}(unit)`,d=n.events.reduce(((e,t)=>[...e,{events:t.between[0],update:`[${f}, ${f}]`},{events:t,update:`[${s}[0], clamp(${f}, 0, ${u})]`}]),[]);if(o){const t=i.signals.data,o=zc.defined(n),u=e.getScaleComponent(a),f=u?u.get("type"):void 0,m=r?{init:cc(r,!0,c)}:{value:[]};return d.push({events:{signal:n.name+Nc},update:yr(f)?`[${c(`${t}[0]`)}, ${c(`${t}[1]`)}]`:"[0, 0]"}),o?[{name:t,on:[]}]:[{name:s,...m,on:d},{name:t,...r?{init:cc(r)}:{},on:[{events:{signal:s},update:`${s}[0] === ${s}[1] ? null : invert(${l}, ${s})`}]}]}{const e=a===Z?0:1,t=n.name+Ac;return[{name:s,...r?{init:`[${t}[0][${e}], ${t}[1][${e}]]`}:{value:[]},on:d}]}}(e,n,r,s&&s[r.index]))),[])),e.hasProjection){const l=t.stringValue(e.projectionName()),c=e.projectionName()+"_center",{x:u,y:f}=n.project.hasChannel,d=u&&u.signals.visual,m=f&&f.signals.visual,p=u?s&&s[u.index]:`${c}[0]`,g=f?s&&s[f.index]:`${c}[1]`,h=t=>e.getSizeSignalRef(t).signal,y=`[[${d?d+"[0]":"0"}, ${m?m+"[0]":"0"}],[${d?d+"[1]":h("width")}, ${m?m+"[1]":h("height")}]]`;if(s&&(i.unshift({name:r+Ac,init:`[scale(${l}, [${u?p[0]:p}, ${f?g[0]:g}]), scale(${l}, [${u?p[1]:p}, ${f?g[1]:g}])]`}),!u||!f)){i.find((e=>e.name===c))||i.unshift({name:c,update:`invert(${l}, [${h("width")}/2, ${h("height")}/2])`})}const v=`vlSelectionTuples(${`intersect(${y}, {markname: ${t.stringValue(e.getName("marks"))}}, unit.mark)`}, ${`{unit: ${Au(e)}}`})`,b=a.map((e=>e.signals.visual));return i.concat({name:o,on:[{events:[...b.length?[{signal:b.join(" || ")}]:[],...s?[{signal:Pc}]:[]],update:v}]})}{if(!zc.defined(n)){const n=r+Nc,o=a.map((n=>{const i=n.channel,{data:r,visual:o}=n.signals,a=t.stringValue(e.scaleName(i)),s=yr(e.getScaleComponent(i).get("type"))?"+":"";return`(!isArray(${r}) || (${s}invert(${a}, ${o})[0] === ${s}${r}[0] && ${s}invert(${a}, ${o})[1] === ${s}${r}[1]))`}));o.length&&i.push({name:n,value:{},on:[{events:a.map((t=>({scale:e.scaleName(t.channel)}))),update:o.join(" && ")+` ? ${n} : {}`}]})}const l=a.map((e=>e.signals.data)),c=`unit: ${Au(e)}, fields: ${r+Sc}, values`;return i.concat({name:o,...s?{init:`{${c}: ${cc(s)}}`}:{},...l.length?{on:[{events:[{signal:l.join(" || ")}],update:`${l.join(" && ")} ? {${c}: [${l}]} : null`}]}:{}})}},topLevelSignals:(e,t,n)=>{if(gm(e)&&e.hasProjection&&t.init){n.filter((e=>e.name===Pc)).length||n.unshift({name:Pc,value:null,on:[{events:"timer{1}",update:`${Pc} === null ? {} : ${Pc}`}]})}return n},marks:(e,n,i)=>{const r=n.name,{x:o,y:a}=n.project.hasChannel,s=o?.signals.visual,l=a?.signals.visual,c=`data(${t.stringValue(n.name+Ou)})`;if(zc.defined(n)||!o&&!a)return i;const u={x:void 0!==o?{signal:`${s}[0]`}:{value:0},y:void 0!==a?{signal:`${l}[0]`}:{value:0},x2:void 0!==o?{signal:`${s}[1]`}:{field:{group:"width"}},y2:void 0!==a?{signal:`${l}[1]`}:{field:{group:"height"}}};if("global"===n.resolve)for(const t of D(u))u[t]=[{test:`${c}.length && ${c}[0].unit === ${Au(e)}`,...u[t]},{value:0}];const{fill:f,fillOpacity:d,cursor:m,...p}=n.mark,g=D(p).reduce(((e,t)=>(e[t]=[{test:[void 0!==o&&`${s}[0] !== ${s}[1]`,void 0!==a&&`${l}[0] !== ${l}[1]`].filter((e=>e)).join(" && "),value:p[t]},{value:null}],e)),{}),h=m??(n.translate?"move":null);return[{name:`${r+Cc}_bg`,type:"rect",clip:!0,encode:{enter:{fill:{value:f},fillOpacity:{value:d}},update:u}},...i,{name:r+Cc,type:"rect",clip:!0,encode:{enter:{...h?{cursor:{value:h}}:{},fill:{value:"transparent"}},update:{...u,...g}}}]}};const Tc={defined:e=>"point"===e.type,signals:(e,n,i)=>{const r=n.name,o=r+Sc,a=n.project,s="(item().isVoronoi ? datum.datum : datum)",l=F(e.component.selection??{}).reduce(((e,t)=>"interval"===t.type?e.concat(t.name+Cc):e),[]).map((e=>`indexof(item().mark.name, '${e}') < 0`)).join(" && "),c="datum && item().mark.marktype !== 'group' && indexof(item().mark.role, 'legend') < 0"+(l?` && ${l}`:"");let u=`unit: ${Au(e)}, `;if(n.project.hasSelectionId)u+=`${hs}: ${s}[${t.stringValue(hs)}]`;else{u+=`fields: ${o}, values: [${a.items.map((n=>{const i=e.fieldDef(n.channel);return i?.bin?`[${s}[${t.stringValue(e.vgField(n.channel,{}))}], ${s}[${t.stringValue(e.vgField(n.channel,{binSuffix:"end"}))}]]`:`${s}[${t.stringValue(n.field)}]`})).join(", ")}]`}const f=n.events;return i.concat([{name:r+_u,on:f?[{events:f,update:`${c} ? {${u}} : null`,force:!0}]:[]}])}};function Ec(e,n,i,r){const o=Lo(n)&&n.condition,a=r(n);if(o){const n=t.array(o).map((t=>{const n=r(t);if(function(e){return e.param}(t)){const{param:i,empty:r}=t;return{test:Uu(e,{param:i,empty:r}),...n}}return{test:Wu(e,t.test),...n}}));return{[i]:[...n,...void 0!==a?[a]:[]]}}return void 0!==a?{[i]:a}:{}}function Mc(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"text";const n=e.encoding[t];return Ec(e,n,t,(t=>Lc(t,e.config)))}function Lc(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"datum";if(e){if(Xo(e))return Fn(e.value);if(Go(e)){const{format:i,formatType:r}=ca(e);return Rr({fieldOrDatumDef:e,format:i,formatType:r,expr:n,config:t})}}}function qc(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{encoding:i,markDef:r,config:o,stack:a}=e,s=i.tooltip;if(t.isArray(s))return{tooltip:Rc({tooltip:s},a,o,n)};{const l=n.reactiveGeom?"datum.datum":"datum";return Ec(e,s,"tooltip",(e=>{const s=Lc(e,o,l);if(s)return s;if(null===e)return;let c=Nn("tooltip",r,o);return!0===c&&(c={content:"encoding"}),t.isString(c)?{value:c}:t.isObject(c)?yn(c)?c:"encoding"===c.content?Rc(i,a,o,n):{signal:l}:void 0}))}}function Uc(e,n,i){let{reactiveGeom:r}=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};const o={...i,...i.tooltipFormat},a={},s=r?"datum.datum":"datum",l=[];function c(i,r){const c=tt(r),u=Yo(i)?i:{...i,type:e[c].type},f=u.title||la(u,o),d=t.array(f).join(", ").replaceAll(/"/g,'\\"');let m;if(zt(r)){const t="x"===r?"x2":"y2",n=ua(e[t]);if(cn(u.bin)&&n){const e=ta(u,{expr:s}),i=ta(n,{expr:s}),{format:r,formatType:l}=ca(u);m=Xr(e,i,r,l,o),a[t]=!0}}if((zt(r)||r===se||r===oe)&&n&&n.fieldChannel===r&&"normalize"===n.offset){const{format:e,formatType:t}=ca(u);m=Rr({fieldOrDatumDef:u,format:e,formatType:t,expr:s,config:o,normalizeStack:!0}).signal}m??=Lc(u,o,s).signal,l.push({channel:r,key:d,value:m})}La(e,((e,t)=>{Ro(e)?c(e,t):qo(e)&&c(e.condition,t)}));const u={};for(const{channel:e,key:t,value:n}of l)a[e]||u[t]||(u[t]=n);return u}function Rc(e,t,n){let{reactiveGeom:i}=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};const r=Uc(e,t,n,{reactiveGeom:i}),o=z(r).map((e=>{let[t,n]=e;return`"${t}": ${n}`}));return o.length>0?{signal:`{${o.join(", ")}}`}:void 0}function Wc(e){const{markDef:t,config:n}=e,i=Nn("aria",t,n);return!1===i?{}:{...i?{aria:i}:{},...Bc(e),...Ic(e)}}function Bc(e){const{mark:t,markDef:n,config:i}=e;if(!1===i.aria)return{};const r=Nn("ariaRoleDescription",n,i);return null!=r?{ariaRoleDescription:{value:r}}:t in $n?{}:{ariaRoleDescription:{value:t}}}function Ic(e){const{encoding:t,markDef:n,config:i,stack:r}=e,o=t.description;if(o)return Ec(e,o,"description",(t=>Lc(t,e.config)));const a=Nn("description",n,i);if(null!=a)return{description:Fn(a)};if(!1===i.aria)return{};const s=Uc(t,r,i);return S(s)?void 0:{description:{signal:z(s).map(((e,t)=>{let[n,i]=e;return`"${t>0?"; ":""}${n}: " + (${i})`})).join(" + ")}}}function Hc(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const{markDef:i,encoding:r,config:o}=t,{vgChannel:a}=n;let{defaultRef:s,defaultValue:l}=n;void 0===s&&(l??=Nn(e,i,o,{vgChannel:a,ignoreVgConfig:!0}),void 0!==l&&(s=Fn(l)));const c=r[e];return Ec(t,c,a??e,(n=>Er({channel:e,channelDef:n,markDef:i,config:o,scaleName:t.scaleName(e),scale:t.getScaleComponent(e),stack:null,defaultRef:s})))}function Vc(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{filled:void 0};const{markDef:n,encoding:i,config:r}=e,{type:o}=n,a=t.filled??Nn("filled",n,r),s=p(["bar","point","circle","square","geoshape"],o)?"transparent":void 0,l=Nn(!0===a?"color":void 0,n,r,{vgChannel:"fill"})??r.mark[!0===a&&"color"]??s,c=Nn(!1===a?"color":void 0,n,r,{vgChannel:"stroke"})??r.mark[!1===a&&"color"],u=a?"fill":"stroke",f={...l?{fill:Fn(l)}:{},...c?{stroke:Fn(c)}:{}};return n.color&&(a?n.fill:n.stroke)&&yi(ei("property",{fill:"fill"in n,stroke:"stroke"in n})),{...f,...Hc("color",e,{vgChannel:u,defaultValue:a?l:c}),...Hc("fill",e,{defaultValue:i.fill?l:void 0}),...Hc("stroke",e,{defaultValue:i.stroke?c:void 0})}}function Gc(e){const{encoding:t,mark:n}=e,i=t.order;return!fo(n)&&Xo(i)?Ec(e,i,"zindex",(e=>Fn(e.value))):{}}function Yc(e){let{channel:t,markDef:n,encoding:i={},model:r,bandPosition:o}=e;const a=`${t}Offset`,s=n[a],l=i[a];if(("xOffset"===a||"yOffset"===a)&&l){return{offsetType:"encoding",offset:Er({channel:a,channelDef:l,markDef:n,config:r?.config,scaleName:r.scaleName(a),scale:r.getScaleComponent(a),stack:null,defaultRef:Fn(s),bandPosition:o})}}const c=n[a];return c?{offsetType:"visual",offset:c}:{}}function Xc(e,t,n){let{defaultPos:i,vgChannel:r}=n;const{encoding:o,markDef:a,config:s,stack:l}=t,c=o[e],u=o[it(e)],f=t.scaleName(e),d=t.getScaleComponent(e),{offset:m,offsetType:p}=Yc({channel:e,markDef:a,encoding:o,model:t,bandPosition:.5}),g=Qc({model:t,defaultPos:i,channel:e,scaleName:f,scale:d}),h=!c&&zt(e)&&(o.latitude||o.longitude)?{field:t.getName(e)}:function(e){const{channel:t,channelDef:n,scaleName:i,stack:r,offset:o,markDef:a}=e;if(Go(n)&&r&&t===r.fieldChannel){if(Ro(n)){let e=n.bandPosition;if(void 0!==e||"text"!==a.type||"radius"!==t&&"theta"!==t||(e=.5),void 0!==e)return Tr({scaleName:i,fieldOrDatumDef:n,startSuffix:"start",bandPosition:e,offset:o})}return jr(n,i,{suffix:"end"},{offset:o})}return Nr(e)}({channel:e,channelDef:c,channel2Def:u,markDef:a,config:s,scaleName:f,scale:d,stack:l,offset:m,defaultRef:g,bandPosition:"encoding"===p?0:void 0});return h?{[r||e]:h}:void 0}function Qc(e){let{model:t,defaultPos:n,channel:i,scaleName:r,scale:o}=e;const{markDef:a,config:s}=t;return()=>{const e=tt(i),l=nt(i),c=Nn(i,a,s,{vgChannel:l});if(void 0!==c)return Mr(i,c);switch(n){case"zeroOrMin":case"zeroOrMax":if(r){const e=o.get("type");if(p([or.LOG,or.TIME,or.UTC],e));else if(o.domainDefinitelyIncludesZero())return{scale:r,value:0}}if("zeroOrMin"===n)return"y"===e?{field:{group:"height"}}:{value:0};switch(e){case"radius":return{signal:`min(${t.width.signal},${t.height.signal})/2`};case"theta":return{signal:"2*PI"};case"x":return{field:{group:"width"}};case"y":return{value:0}}break;case"mid":return{...t[rt(i)],mult:.5}}}}const Jc={left:"x",center:"xc",right:"x2"},Kc={top:"y",middle:"yc",bottom:"y2"};function Zc(e,t,n){let i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"middle";if("radius"===e||"theta"===e)return nt(e);const r="x"===e?"align":"baseline",o=Nn(r,t,n);let a;return yn(o)?(yi(function(e){return`The ${e} for range marks cannot be an expression`}(r)),a=void 0):a=o,"x"===e?Jc[a||("top"===i?"left":"center")]:Kc[a||i]}function eu(e,t,n){let{defaultPos:i,defaultPos2:r,range:o}=n;return o?tu(e,t,{defaultPos:i,defaultPos2:r}):Xc(e,t,{defaultPos:i})}function tu(e,t,n){let{defaultPos:i,defaultPos2:r}=n;const{markDef:o,config:a}=t,s=it(e),l=rt(e),c=function(e,t,n){const{encoding:i,mark:r,markDef:o,stack:a,config:s}=e,l=tt(n),c=rt(n),u=nt(n),f=i[l],d=e.scaleName(l),m=e.getScaleComponent(l),{offset:p}=Yc(n in i||n in o?{channel:n,markDef:o,encoding:i,model:e}:{channel:l,markDef:o,encoding:i,model:e});if(!f&&("x2"===n||"y2"===n)&&(i.latitude||i.longitude)){const t=rt(n),i=e.markDef[t];return null!=i?{[t]:{value:i}}:{[u]:{field:e.getName(n)}}}const g=function(e){let{channel:t,channelDef:n,channel2Def:i,markDef:r,config:o,scaleName:a,scale:s,stack:l,offset:c,defaultRef:u}=e;if(Go(n)&&l&&t.charAt(0)===l.fieldChannel.charAt(0))return jr(n,a,{suffix:"start"},{offset:c});return Nr({channel:t,channelDef:i,scaleName:a,scale:s,stack:l,markDef:r,config:o,offset:c,defaultRef:u})}({channel:n,channelDef:f,channel2Def:i[n],markDef:o,config:s,scaleName:d,scale:m,stack:a,offset:p,defaultRef:void 0});if(void 0!==g)return{[u]:g};return nu(n,o)||nu(n,{[n]:An(n,o,s.style),[c]:An(c,o,s.style)})||nu(n,s[r])||nu(n,s.mark)||{[u]:Qc({model:e,defaultPos:t,channel:n,scaleName:d,scale:m})()}}(t,r,s);return{...Xc(e,t,{defaultPos:i,vgChannel:c[l]?Zc(e,o,a):nt(e)}),...c}}function nu(e,t){const n=rt(e),i=nt(e);if(void 0!==t[i])return{[i]:Mr(e,t[i])};if(void 0!==t[e])return{[i]:Mr(e,t[e])};if(t[n]){const i=t[n];if(!bo(i))return{[n]:Mr(e,i)};yi(function(e){return`Position range does not support relative band size for ${e}.`}(n))}}function iu(e,n){const{config:i,encoding:r,markDef:o}=e,a=o.type,s=it(n),l=rt(n),c=r[n],u=r[s],f=e.getScaleComponent(n),d=f?f.get("type"):void 0,m=o.orient,p=r[l]??r.size??Nn("size",o,i,{vgChannel:l}),g=ot(n),h="bar"===a&&("x"===n?"vertical"===m:"horizontal"===m);return!Ro(c)||!(ln(c.bin)||cn(c.bin)||c.timeUnit&&!u)||p&&!bo(p)||r[g]||hr(d)?(Go(c)&&hr(d)||h)&&!u?function(e,n,i){const{markDef:r,encoding:o,config:a,stack:s}=i,l=r.orient,c=i.scaleName(n),u=i.getScaleComponent(n),f=rt(n),d=it(n),m=ot(n),p=i.scaleName(m),g=i.getScaleComponent(at(n)),h="horizontal"===l&&"y"===n||"vertical"===l&&"x"===n;let y;(o.size||r.size)&&(h?y=Hc("size",i,{vgChannel:f,defaultRef:Fn(r.size)}):yi(function(e){return`Cannot apply size to non-oriented mark "${e}".`}(r.type)));const v=!!y,b=To({channel:n,fieldDef:e,markDef:r,config:a,scaleType:(u||g)?.get("type"),useVlSizeChannel:h});y=y||{[f]:ru(f,p||c,g||u,a,b,!!e,r.type)};const x="band"===(u||g)?.get("type")&&bo(b)&&!v?"top":"middle",$=Zc(n,r,a,x),w="xc"===$||"yc"===$,{offset:k,offsetType:S}=Yc({channel:n,markDef:r,encoding:o,model:i,bandPosition:w?.5:0}),D=Nr({channel:n,channelDef:e,markDef:r,config:a,scaleName:c,scale:u,stack:s,offset:k,defaultRef:Qc({model:i,defaultPos:"mid",channel:n,scaleName:c,scale:u}),bandPosition:w?"encoding"===S?0:.5:yn(b)?{signal:`(1-${b})/2`}:bo(b)?(1-b.band)/2:0});if(f)return{[$]:D,...y};{const e=nt(d),n=y[f],i=k?{...n,offset:k}:n;return{[$]:D,[e]:t.isArray(D)?[D[0],{...D[1],offset:i}]:{...D,offset:i}}}}(c,n,e):tu(n,e,{defaultPos:"zeroOrMax",defaultPos2:"zeroOrMin"}):function(e){let{fieldDef:t,fieldDef2:n,channel:i,model:r}=e;const{config:o,markDef:a,encoding:s}=r,l=r.getScaleComponent(i),c=r.scaleName(i),u=l?l.get("type"):void 0,f=l.get("reverse"),d=To({channel:i,fieldDef:t,markDef:a,config:o,scaleType:u}),m=r.component.axes[i]?.[0],p=m?.get("translate")??.5,g=zt(i)?Nn("binSpacing",a,o)??0:0,h=it(i),y=nt(i),v=nt(h),b=Pn("minBandSize",a,o),{offset:x}=Yc({channel:i,markDef:a,encoding:s,model:r,bandPosition:0}),{offset:$}=Yc({channel:h,markDef:a,encoding:s,model:r,bandPosition:0}),w=function(e){let{scaleName:t,fieldDef:n}=e;const i=ta(n,{expr:"datum"});return`abs(scale("${t}", ${ta(n,{expr:"datum",suffix:"end"})}) - scale("${t}", ${i}))`}({fieldDef:t,scaleName:c}),k=ou(i,g,f,p,x,b,w),S=ou(h,g,f,p,$??x,b,w),D=yn(d)?{signal:`(1-${d.signal})/2`}:bo(d)?(1-d.band)/2:.5,F=jo({fieldDef:t,fieldDef2:n,markDef:a,config:o});if(ln(t.bin)||t.timeUnit){const e=t.timeUnit&&.5!==F;return{[v]:au({fieldDef:t,scaleName:c,bandPosition:D,offset:S,useRectOffsetField:e}),[y]:au({fieldDef:t,scaleName:c,bandPosition:yn(D)?{signal:`1-${D.signal}`}:1-D,offset:k,useRectOffsetField:e})}}if(cn(t.bin)){const e=jr(t,c,{},{offset:S});if(Ro(n))return{[v]:e,[y]:jr(n,c,{},{offset:k})};if(un(t.bin)&&t.bin.step)return{[v]:e,[y]:{signal:`scale("${c}", ${ta(t,{expr:"datum"})} + ${t.bin.step})`,offset:k}}}return void yi(pi(h))}({fieldDef:c,fieldDef2:u,channel:n,model:e})}function ru(e,n,i,r,o,a,s){if(bo(o)){if(!i)return{mult:o.band,field:{group:e}};{const e=i.get("type");if("band"===e){let e=`bandwidth('${n}')`;1!==o.band&&(e=`${o.band} * ${e}`);const t=Pn("minBandSize",{type:s},r);return{signal:t?`max(${On(t)}, ${e})`:e}}1!==o.band&&(yi(function(e){return`Cannot use the relative band size with ${e} scale.`}(e)),o=void 0)}}else{if(yn(o))return o;if(o)return{value:o}}if(i){const e=i.get("range");if(vn(e)&&t.isNumber(e.step))return{value:e.step-2}}if(!a){const{bandPaddingInner:n,barBandPaddingInner:i,rectBandPaddingInner:o}=r.scale,a=U(n,"bar"===s?i:o);if(yn(a))return{signal:`(1 - (${a.signal})) * ${e}`};if(t.isNumber(a))return{signal:`${1-a} * ${e}`}}return{value:Cs(r.view,e)-2}}function ou(e,t,n,i,r,o,a){if(Ae(e))return 0;const s="x"===e||"y2"===e,l=s?-t/2:t/2;if(yn(n)||yn(r)||yn(i)||o){const e=On(n),t=On(r),c=On(i),u=On(o),f=o?`(${a} < ${u} ? ${s?"":"-"}0.5 * (${u} - (${a})) : ${l})`:l;return{signal:(c?`${c} + `:"")+(e?`(${e} ? -1 : 1) * `:"")+(t?`(${t} + ${f})`:f)}}return r=r||0,i+(n?-r-l:+r+l)}function au(e){let{fieldDef:t,scaleName:n,bandPosition:i,offset:r,useRectOffsetField:o}=e;return Tr({scaleName:n,fieldOrDatumDef:t,bandPosition:i,offset:r,...o?{startSuffix:bc,endSuffix:xc}:{}})}const su=new Set(["aria","width","height"]);function lu(e,t){const{fill:n,stroke:i}="include"===t.color?Vc(e):{};return{...uu(e.markDef,t),...cu(e,"fill",n),...cu(e,"stroke",i),...Hc("opacity",e),...Hc("fillOpacity",e),...Hc("strokeOpacity",e),...Hc("strokeWidth",e),...Hc("strokeDash",e),...Gc(e),...qc(e),...Mc(e,"href"),...Wc(e)}}function cu(e,n,i){const{config:r,mark:o,markDef:a}=e;if("hide"===Nn("invalid",a,r)&&i&&!fo(o)){const r=function(e,t){let{invalid:n=!1,channels:i}=t;const r=i.reduce(((t,n)=>{const i=e.getScaleComponent(n);if(i){const r=i.get("type"),o=e.vgField(n,{expr:"datum"});o&&yr(r)&&(t[o]=!0)}return t}),{}),o=D(r);if(o.length>0){const e=n?"||":"&&";return o.map((e=>Ar(e,n))).join(` ${e} `)}return}(e,{invalid:!0,channels:It});if(r)return{[n]:[{test:r,value:null},...t.array(i)]}}return i?{[n]:i}:{}}function uu(e,t){return xn.reduce(((n,i)=>(su.has(i)||void 0===e[i]||"ignore"===t[i]||(n[i]=Fn(e[i])),n)),{})}function fu(e){const{config:t,markDef:n}=e;if(Nn("invalid",n,t)){const t=function(e,t){let{invalid:n=!1,channels:i}=t;const r=i.reduce(((t,n)=>{const i=e.getScaleComponent(n);if(i){const r=i.get("type"),o=e.vgField(n,{expr:"datum",binSuffix:e.stack?.impute?"mid":void 0});o&&yr(r)&&(t[o]=!0)}return t}),{}),o=D(r);if(o.length>0){const e=n?"||":"&&";return o.map((e=>Ar(e,n))).join(` ${e} `)}return}(e,{channels:Ft});if(t)return{defined:{signal:t}}}return{}}function du(e,t){if(void 0!==t)return{[e]:Fn(t)}}const mu="voronoi",pu={defined:e=>"point"===e.type&&e.nearest,parse:(e,t)=>{if(t.events)for(const n of t.events)n.markname=e.getName(mu)},marks:(e,t,n)=>{const{x:i,y:r}=t.project.hasChannel,o=e.mark;if(fo(o))return yi(`The "nearest" transform is not supported for ${o} marks.`),n;const a={name:e.getName(mu),type:"path",interactive:!0,from:{data:e.getName("marks")},encode:{update:{fill:{value:"transparent"},strokeWidth:{value:.35},stroke:{value:"transparent"},isVoronoi:{value:!0},...qc(e,{reactiveGeom:!0})}},transform:[{type:"voronoi",x:{expr:i||!r?"datum.datum.x || 0":"0"},y:{expr:r||!i?"datum.datum.y || 0":"0"},size:[e.getSizeSignalRef("width"),e.getSizeSignalRef("height")]}]};let s=0,l=!1;return n.forEach(((t,n)=>{const i=t.name??"";i===e.component.mark[0].name?s=n:i.indexOf(mu)>=0&&(l=!0)})),l||n.splice(s+1,0,a),n}},gu={defined:e=>"point"===e.type&&"global"===e.resolve&&e.bind&&"scales"!==e.bind&&!vs(e.bind),parse:(e,t,n)=>Tu(t,n),topLevelSignals:(e,n,i)=>{const r=n.name,o=n.project,a=n.bind,s=n.init&&n.init[0],l=pu.defined(n)?"(item().isVoronoi ? datum.datum : datum)":"datum";return o.items.forEach(((e,o)=>{const c=_(`${r}_${e.field}`);i.filter((e=>e.name===c)).length||i.unshift({name:c,...s?{init:cc(s[o])}:{value:null},on:n.events?[{events:n.events,update:`datum && item().mark.marktype !== 'group' ? ${l}[${t.stringValue(e.field)}] : null`}]:[],bind:a[e.field]??a[e.channel]??a})})),i},signals:(e,t,n)=>{const i=t.name,r=t.project,o=n.filter((e=>e.name===i+_u))[0],a=i+Sc,s=r.items.map((e=>_(`${i}_${e.field}`))),l=s.map((e=>`${e} !== null`)).join(" && ");return s.length&&(o.update=`${l} ? {fields: ${a}, values: [${s.join(", ")}]} : null`),delete o.value,delete o.on,n}},hu="_toggle",yu={defined:e=>"point"===e.type&&!!e.toggle,signals:(e,t,n)=>n.concat({name:t.name+hu,value:!1,on:[{events:t.events,update:t.toggle}]}),modifyExpr:(e,t)=>{const n=t.name+_u,i=t.name+hu;return`${i} ? null : ${n}, `+("global"===t.resolve?`${i} ? null : true, `:`${i} ? null : {unit: ${Au(e)}}, `)+`${i} ? ${n} : null`}},vu={defined:e=>void 0!==e.clear&&!1!==e.clear,parse:(e,n)=>{n.clear&&(n.clear=t.isString(n.clear)?t.parseSelector(n.clear,"view"):n.clear)},topLevelSignals:(e,t,n)=>{if(gu.defined(t))for(const e of t.project.items){const i=n.findIndex((n=>n.name===_(`${t.name}_${e.field}`)));-1!==i&&n[i].on.push({events:t.clear,update:"null"})}return n},signals:(e,t,n)=>{function i(e,i){-1!==e&&n[e].on&&n[e].on.push({events:t.clear,update:i})}if("interval"===t.type)for(const e of t.project.items){const t=n.findIndex((t=>t.name===e.signals.visual));if(i(t,"[0, 0]"),-1===t){i(n.findIndex((t=>t.name===e.signals.data)),"null")}}else{let e=n.findIndex((e=>e.name===t.name+_u));i(e,"null"),yu.defined(t)&&(e=n.findIndex((e=>e.name===t.name+hu)),i(e,"false"))}return n}},bu={defined:e=>{const t="global"===e.resolve&&e.bind&&vs(e.bind),n=1===e.project.items.length&&e.project.items[0].field!==hs;return t&&!n&&yi("Legend bindings are only supported for selections over an individual field or encoding channel."),t&&n},parse:(e,n,i)=>{const r=l(i);if(r.select=t.isString(r.select)?{type:r.select,toggle:n.toggle}:{...r.select,toggle:n.toggle},Tu(n,r),t.isObject(i.select)&&(i.select.on||i.select.clear)){const e='event.item && indexof(event.item.mark.role, "legend") < 0';for(const i of n.events)i.filter=t.array(i.filter??[]),i.filter.includes(e)||i.filter.push(e)}const o=bs(n.bind)?n.bind.legend:"click",a=t.isString(o)?t.parseSelector(o,"view"):t.array(o);n.bind={legend:{merge:a}}},topLevelSignals:(e,t,n)=>{const i=t.name,r=bs(t.bind)&&t.bind.legend,o=e=>t=>{const n=l(t);return n.markname=e,n};for(const e of t.project.items){if(!e.hasLegend)continue;const a=`${_(e.field)}_legend`,s=`${i}_${a}`;if(0===n.filter((e=>e.name===s)).length){const e=r.merge.map(o(`${a}_symbols`)).concat(r.merge.map(o(`${a}_labels`))).concat(r.merge.map(o(`${a}_entries`)));n.unshift({name:s,...t.init?{}:{value:null},on:[{events:e,update:"isDefined(datum.value) ? datum.value : item().items[0].items[0].datum.value",force:!0},{events:r.merge,update:`!event.item || !datum ? null : ${s}`,force:!0}]})}}return n},signals:(e,t,n)=>{const i=t.name,r=t.project,o=n.find((e=>e.name===i+_u)),a=i+Sc,s=r.items.filter((e=>e.hasLegend)).map((e=>_(`${i}_${_(e.field)}_legend`))),l=`${s.map((e=>`${e} !== null`)).join(" && ")} ? {fields: ${a}, values: [${s.join(", ")}]} : null`;t.events&&s.length>0?o.on.push({events:s.map((e=>({signal:e}))),update:l}):s.length>0&&(o.update=l,delete o.value,delete o.on);const c=n.find((e=>e.name===i+hu)),u=bs(t.bind)&&t.bind.legend;return c&&(t.events?c.on.push({...c.on[0],events:u}):c.on[0].events=u),n}};const xu="_translate_anchor",$u="_translate_delta",wu={defined:e=>"interval"===e.type&&e.translate,signals:(e,n,i)=>{const r=n.name,o=zc.defined(n),a=r+xu,{x:s,y:l}=n.project.hasChannel;let c=t.parseSelector(n.translate,"scope");return o||(c=c.map((e=>(e.between[0].markname=r+Cc,e)))),i.push({name:a,value:{},on:[{events:c.map((e=>e.between[0])),update:"{x: x(unit), y: y(unit)"+(void 0!==s?`, extent_x: ${o?Oc(e,Z):`slice(${s.signals.visual})`}`:"")+(void 0!==l?`, extent_y: ${o?Oc(e,ee):`slice(${l.signals.visual})`}`:"")+"}"}]},{name:r+$u,value:{},on:[{events:c,update:`{x: ${a}.x - x(unit), y: ${a}.y - y(unit)}`}]}),void 0!==s&&ku(e,n,s,"width",i),void 0!==l&&ku(e,n,l,"height",i),i}};function ku(e,t,n,i,r){const o=t.name,a=o+xu,s=o+$u,l=n.channel,c=zc.defined(t),u=r.filter((e=>e.name===n.signals[c?"data":"visual"]))[0],f=e.getSizeSignalRef(i).signal,d=e.getScaleComponent(l),m=d&&d.get("type"),p=d&&d.get("reverse"),g=`${a}.extent_${l}`,h=`${c&&d?"log"===m?"panLog":"symlog"===m?"panSymlog":"pow"===m?"panPow":"panLinear":"panLinear"}(${g}, ${`${c?l===Z?p?"":"-":p?"-":"":""}${s}.${l} / ${c?`${f}`:`span(${g})`}`}${c?"pow"===m?`, ${d.get("exponent")??1}`:"symlog"===m?`, ${d.get("constant")??1}`:"":""})`;u.on.push({events:{signal:s},update:c?h:`clampRange(${h}, 0, ${f})`})}const Su="_zoom_anchor",Du="_zoom_delta",Fu={defined:e=>"interval"===e.type&&e.zoom,signals:(e,n,i)=>{const r=n.name,o=zc.defined(n),a=r+Du,{x:s,y:l}=n.project.hasChannel,c=t.stringValue(e.scaleName(Z)),u=t.stringValue(e.scaleName(ee));let f=t.parseSelector(n.zoom,"scope");return o||(f=f.map((e=>(e.markname=r+Cc,e)))),i.push({name:r+Su,on:[{events:f,update:o?"{"+[c?`x: invert(${c}, x(unit))`:"",u?`y: invert(${u}, y(unit))`:""].filter((e=>e)).join(", ")+"}":"{x: x(unit), y: y(unit)}"}]},{name:a,on:[{events:f,force:!0,update:"pow(1.001, event.deltaY * pow(16, event.deltaMode))"}]}),void 0!==s&&zu(e,n,s,"width",i),void 0!==l&&zu(e,n,l,"height",i),i}};function zu(e,t,n,i,r){const o=t.name,a=n.channel,s=zc.defined(t),l=r.filter((e=>e.name===n.signals[s?"data":"visual"]))[0],c=e.getSizeSignalRef(i).signal,u=e.getScaleComponent(a),f=u&&u.get("type"),d=s?Oc(e,a):l.name,m=o+Du,p=`${s&&u?"log"===f?"zoomLog":"symlog"===f?"zoomSymlog":"pow"===f?"zoomPow":"zoomLinear":"zoomLinear"}(${d}, ${`${o}${Su}.${a}`}, ${m}${s?"pow"===f?`, ${u.get("exponent")??1}`:"symlog"===f?`, ${u.get("constant")??1}`:"":""})`;l.on.push({events:{signal:m},update:s?p:`clampRange(${p}, 0, ${c})`})}const Ou="_store",_u="_tuple",Cu="_modify",Nu="vlSelectionResolve",Pu=[Tc,jc,Fc,yu,gu,zc,bu,vu,wu,Fu,pu];function Au(e){let{escape:n}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{escape:!0},i=n?t.stringValue(e.name):e.name;const r=function(e){let t=e.parent;for(;t&&!hm(t);)t=t.parent;return t}(e);if(r){const{facet:e}=r;for(const n of Re)e[n]&&(i+=` + '__facet_${n}_' + (facet[${t.stringValue(r.vgField(n))}])`)}return i}function ju(e){return F(e.component.selection??{}).reduce(((e,t)=>e||t.project.hasSelectionId),!1)}function Tu(e,n){!t.isString(n.select)&&n.select.on||delete e.events,!t.isString(n.select)&&n.select.clear||delete e.clear,!t.isString(n.select)&&n.select.toggle||delete e.toggle}function Eu(e){const t=[];return"Identifier"===e.type?[e.name]:"Literal"===e.type?[e.value]:("MemberExpression"===e.type&&(t.push(...Eu(e.object)),t.push(...Eu(e.property))),t)}function Mu(e){return"MemberExpression"===e.object.type?Mu(e.object):"datum"===e.object.name}function Lu(e){const n=t.parseExpression(e),i=new Set;return n.visit((e=>{"MemberExpression"===e.type&&Mu(e)&&i.add(Eu(e).slice(1).join("."))})),i}class qu extends pc{clone(){return new qu(null,this.model,l(this.filter))}constructor(e,t,n){super(e),this.model=t,this.filter=n,this.expr=Wu(this.model,this.filter,this),this._dependentFields=Lu(this.expr)}dependentFields(){return this._dependentFields}producedFields(){return new Set}assemble(){return{type:"filter",expr:this.expr}}hash(){return`Filter ${this.expr}`}}function Uu(e,n,i){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"datum";const o=t.isString(n)?n:n.param,a=_(o),s=t.stringValue(a+Ou);let l;try{l=e.getSelectionComponent(a,o)}catch(e){return`!!${a}`}if(l.project.timeUnit){const t=i??e.component.data.raw,n=l.project.timeUnit.clone();t.parent?n.insertAsParentOf(t):t.parent=n}const c=`${l.project.hasSelectionId?"vlSelectionIdTest(":"vlSelectionTest("}${s}, ${r}${"global"===l.resolve?")":`, ${t.stringValue(l.resolve)})`}`,u=`length(data(${s}))`;return!1===n.empty?`${u} && ${c}`:`!${u} || ${c}`}function Ru(e,n,i){const r=_(n),o=i.encoding;let a,s=i.field;try{a=e.getSelectionComponent(r,n)}catch(e){return r}if(o||s){if(o&&!s){const e=a.project.items.filter((e=>e.channel===o));!e.length||e.length>1?(s=a.project.items[0].field,yi((e.length?"Multiple ":"No ")+`matching ${t.stringValue(o)} encoding found for selection ${t.stringValue(i.param)}. `+`Using "field": ${t.stringValue(s)}.`)):s=e[0].field}}else s=a.project.items[0].field,a.project.items.length>1&&yi(`A "field" or "encoding" must be specified when using a selection as a scale domain. Using "field": ${t.stringValue(s)}.`);return`${a.name}[${t.stringValue(E(s))}]`}function Wu(e,n,i){return C(n,(n=>t.isString(n)?n:function(e){return e?.param}(n)?Uu(e,n,i):Xi(n)))}function Bu(e,t,n,i){e.encode??={},e.encode[t]??={},e.encode[t].update??={},e.encode[t].update[n]=i}function Iu(e,n,i){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{header:!1};const{disable:o,orient:a,scale:s,labelExpr:l,title:c,zindex:u,...f}=e.combine();if(!o){for(const e in f){const i=Sa[e],r=f[e];if(i&&i!==n&&"both"!==i)delete f[e];else if(wa(r)){const{condition:n,...i}=r,o=t.array(n),a=$a[e];if(a){const{vgProp:t,part:n}=a;Bu(f,n,t,[...o.map((e=>{const{test:t,...n}=e;return{test:Wu(null,t),...n}})),i]),delete f[e]}else if(null===a){const t={signal:o.map((e=>{const{test:t,...n}=e;return`${Wu(null,t)} ? ${zn(n)} : `})).join("")+zn(i)};f[e]=t}}else if(yn(r)){const t=$a[e];if(t){const{vgProp:n,part:i}=t;Bu(f,i,n,r),delete f[e]}}p(["labelAlign","labelBaseline"],e)&&null===f[e]&&delete f[e]}if("grid"===n){if(!f.grid)return;if(f.encode){const{grid:e}=f.encode;f.encode={...e?{grid:e}:{}},S(f.encode)&&delete f.encode}return{scale:s,orient:a,...f,domain:!1,labels:!1,aria:!1,maxExtent:0,minExtent:0,ticks:!1,zindex:U(u,0)}}{if(!r.header&&e.mainExtracted)return;if(void 0!==l){let e=l;f.encode?.labels?.update&&yn(f.encode.labels.update.text)&&(e=M(l,"datum.label",f.encode.labels.update.text.signal)),Bu(f,"labels","text",{signal:e})}if(null===f.labelAlign&&delete f.labelAlign,f.encode){for(const t of ka)e.hasAxisPart(t)||delete f.encode[t];S(f.encode)&&delete f.encode}const n=function(e,n){if(e)return t.isArray(e)&&!hn(e)?e.map((e=>la(e,n))).join(", "):e}(c,i);return{scale:s,orient:a,grid:!1,...n?{title:n}:{},...f,...!1===i.aria?{aria:!1}:{},zindex:U(u,0)}}}}function Hu(e){const{axes:t}=e.component,n=[];for(const i of Ft)if(t[i])for(const r of t[i])if(!r.get("disable")&&!r.get("gridScale")){const t="x"===i?"height":"width",r=e.getSizeSignalRef(t).signal;t!==r&&n.push({name:t,update:r})}return n}function Vu(e,t,n,i){return Object.assign.apply(null,[{},...e.map((e=>{if("axisOrient"===e){const e="x"===n?"bottom":"left",r=t["x"===n?"axisBottom":"axisLeft"]||{},o=t["x"===n?"axisTop":"axisRight"]||{},a=new Set([...D(r),...D(o)]),s={};for(const t of a.values())s[t]={signal:`${i.signal} === "${e}" ? ${On(r[t])} : ${On(o[t])}`};return s}return t[e]}))])}function Gu(e,n){const i=[{}];for(const r of e){let e=n[r]?.style;if(e){e=t.array(e);for(const t of e)i.push(n.style[t])}}return Object.assign.apply(null,i)}function Yu(e,t,n){let i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};const r=jn(e,n,t);if(void 0!==r)return{configFrom:"style",configValue:r};for(const t of["vlOnlyAxisConfig","vgAxisConfig","axisConfigStyle"])if(void 0!==i[t]?.[e])return{configFrom:t,configValue:i[t][e]};return{}}const Xu={scale:e=>{let{model:t,channel:n}=e;return t.scaleName(n)},format:e=>{let{format:t}=e;return t},formatType:e=>{let{formatType:t}=e;return t},grid:e=>{let{fieldOrDatumDef:t,axis:n,scaleType:i}=e;return n.grid??function(e,t){return!hr(e)&&Ro(t)&&!ln(t?.bin)&&!cn(t?.bin)}(i,t)},gridScale:e=>{let{model:t,channel:n}=e;return function(e,t){const n="x"===t?"y":"x";if(e.getScaleComponent(n))return e.scaleName(n);return}(t,n)},labelAlign:e=>{let{axis:t,labelAngle:n,orient:i,channel:r}=e;return t.labelAlign||Ku(n,i,r)},labelAngle:e=>{let{labelAngle:t}=e;return t},labelBaseline:e=>{let{axis:t,labelAngle:n,orient:i,channel:r}=e;return t.labelBaseline||Ju(n,i,r)},labelFlush:e=>{let{axis:t,fieldOrDatumDef:n,channel:i}=e;return t.labelFlush??function(e,t){if("x"===t&&p(["quantitative","temporal"],e))return!0;return}(n.type,i)},labelOverlap:e=>{let{axis:n,fieldOrDatumDef:i,scaleType:r}=e;return n.labelOverlap??function(e,n,i,r){if(i&&!t.isObject(r)||"nominal"!==e&&"ordinal"!==e)return"log"!==n&&"symlog"!==n||"greedy";return}(i.type,r,Ro(i)&&!!i.timeUnit,Ro(i)?i.sort:void 0)},orient:e=>{let{orient:t}=e;return t},tickCount:e=>{let{channel:t,model:n,axis:i,fieldOrDatumDef:r,scaleType:o}=e;const a="x"===t?"width":"y"===t?"height":void 0,s=a?n.getSizeSignalRef(a):void 0;return i.tickCount??function(e){let{fieldOrDatumDef:t,scaleType:n,size:i,values:r}=e;if(!r&&!hr(n)&&"log"!==n){if(Ro(t)){if(ln(t.bin))return{signal:`ceil(${i.signal}/10)`};if(t.timeUnit&&p(["month","hours","day","quarter"],Ei(t.timeUnit)?.unit))return}return{signal:`ceil(${i.signal}/40)`}}return}({fieldOrDatumDef:r,scaleType:o,size:s,values:i.values})},tickMinStep:function(e){let{format:t,fieldOrDatumDef:n}=e;if("d"===t)return 1;if(Ro(n)){const{timeUnit:e}=n;if(e){const t=Mi(e);if(t)return{signal:t}}}return},title:e=>{let{axis:t,model:n,channel:i}=e;if(void 0!==t.title)return t.title;const r=Zu(n,i);if(void 0!==r)return r;const o=n.typedFieldDef(i),a="x"===i?"x2":"y2",s=n.fieldDef(a);return En(o?[Po(o)]:[],Ro(s)?[Po(s)]:[])},values:e=>{let{axis:n,fieldOrDatumDef:i}=e;return function(e,n){const i=e.values;if(t.isArray(i))return ba(n,i);if(yn(i))return i;return}(n,i)},zindex:e=>{let{axis:t,fieldOrDatumDef:n,mark:i}=e;return t.zindex??function(e,t){if("rect"===e&&na(t))return 1;return 0}(i,n)}};function Qu(e){return`(((${e.signal} % 360) + 360) % 360)`}function Ju(e,t,n,i){if(void 0!==e){if("x"===n){if(yn(e)){const n=Qu(e);return{signal:`(45 < ${n} && ${n} < 135) || (225 < ${n} && ${n} < 315) ? "middle" :(${n} <= 45 || 315 <= ${n}) === ${yn(t)?`(${t.signal} === "top")`:"top"===t} ? "bottom" : "top"`}}if(45{if(Qo(t)&&Oo(t.sort)){const{field:i,timeUnit:r}=t,o=t.sort,a=o.map(((e,t)=>`${Xi({field:i,timeUnit:r,equal:e})} ? ${t} : `)).join("")+o.length;e=new ef(e,{calculate:a,as:tf(t,n,{forAs:!0})})}})),e}producedFields(){return new Set([this.transform.as])}dependentFields(){return this._dependentFields}assemble(){return{type:"formula",expr:this.transform.calculate,as:this.transform.as}}hash(){return`Calculate ${d(this.transform)}`}}function tf(e,t,n){return ta(e,{prefix:t,suffix:"sort_index",...n})}function nf(e,t){return p(["top","bottom"],t)?"column":p(["left","right"],t)||"row"===e?"row":"column"}function rf(e,t,n,i){const r="row"===i?n.headerRow:"column"===i?n.headerColumn:n.headerFacet;return U((t||{})[e],r[e],n.header[e])}function of(e,t,n,i){const r={};for(const o of e){const e=rf(o,t||{},n,i);void 0!==e&&(r[o]=e)}return r}const af=["row","column"],sf=["header","footer"];function lf(e,t){const n=e.component.layoutHeaders[t].title,i=e.config?e.config:void 0,r=e.component.layoutHeaders[t].facetFieldDef?e.component.layoutHeaders[t].facetFieldDef:void 0,{titleAnchor:o,titleAngle:a,titleOrient:s}=of(["titleAnchor","titleAngle","titleOrient"],r.header,i,t),l=nf(t,s),c=H(a);return{name:`${t}-title`,type:"group",role:`${l}-title`,title:{text:n,..."row"===t?{orient:"left"}:{},style:"guide-title",...uf(c,l),...cf(l,c,o),...yf(i,r,t,ds,us)}}}function cf(e,t){switch(arguments.length>2&&void 0!==arguments[2]?arguments[2]:"middle"){case"start":return{align:"left"};case"end":return{align:"right"}}const n=Ku(t,"row"===e?"left":"top","row"===e?"y":"x");return n?{align:n}:{}}function uf(e,t){const n=Ju(e,"row"===t?"left":"top","row"===t?"y":"x",!0);return n?{baseline:n}:{}}function ff(e,t){const n=e.component.layoutHeaders[t],i=[];for(const r of sf)if(n[r])for(const o of n[r]){const a=pf(e,t,r,n,o);null!=a&&i.push(a)}return i}function df(e,n){const{sort:i}=e;return zo(i)?{field:ta(i,{expr:"datum"}),order:i.order??"ascending"}:t.isArray(i)?{field:tf(e,n,{expr:"datum"}),order:"ascending"}:{field:ta(e,{expr:"datum"}),order:i??"ascending"}}function mf(e,t,n){const{format:i,formatType:r,labelAngle:o,labelAnchor:a,labelOrient:s,labelExpr:l}=of(["format","formatType","labelAngle","labelAnchor","labelOrient","labelExpr"],e.header,n,t),c=Rr({fieldOrDatumDef:e,format:i,formatType:r,expr:"parent",config:n}).signal,u=nf(t,s);return{text:{signal:l?M(M(l,"datum.label",c),"datum.value",ta(e,{expr:"parent"})):c},..."row"===t?{orient:"left"}:{},style:"guide-label",frame:"group",...uf(o,u),...cf(u,o,a),...yf(n,e,t,ms,fs)}}function pf(e,t,n,i,r){if(r){let o=null;const{facetFieldDef:a}=i,s=e.config?e.config:void 0;if(a&&r.labels){const{labelOrient:e}=of(["labelOrient"],a.header,s,t);("row"===t&&!p(["top","bottom"],e)||"column"===t&&!p(["left","right"],e))&&(o=mf(a,t,s))}const l=hm(e)&&!_o(e.facet),c=r.axes,u=c?.length>0;if(o||u){const s="row"===t?"height":"width";return{name:e.getName(`${t}_${n}`),type:"group",role:`${t}-${n}`,...i.facetFieldDef?{from:{data:e.getName(`${t}_domain`)},sort:df(a,t)}:{},...u&&l?{from:{data:e.getName(`facet_domain_${t}`)}}:{},...o?{title:o}:{},...r.sizeSignal?{encode:{update:{[s]:r.sizeSignal}}}:{},...u?{axes:c}:{}}}}return null}const gf={column:{start:0,end:1},row:{start:1,end:0}};function hf(e,t){return gf[t][e]}function yf(e,t,n,i,r){const o={};for(const a of i){if(!r[a])continue;const i=rf(a,t?.header,e,n);void 0!==i&&(o[r[a]]=i)}return o}function vf(e){return[...bf(e,"width"),...bf(e,"height"),...bf(e,"childWidth"),...bf(e,"childHeight")]}function bf(e,t){const n="width"===t?"x":"y",i=e.component.layoutSize.get(t);if(!i||"merged"===i)return[];const r=e.getSizeSignalRef(t).signal;if("step"===i){const t=e.getScaleComponent(n);if(t){const i=t.get("type"),o=t.get("range");if(hr(i)&&vn(o)){const i=e.scaleName(n);if(hm(e.parent)){if("independent"===e.parent.component.resolve.scale[n])return[xf(i,o)]}return[xf(i,o),{name:r,update:$f(i,t,`domain('${i}').length`)}]}}throw new Error("layout size is step although width/height is not step.")}if("container"==i){const t=r.endsWith("width"),n=t?"containerSize()[0]":"containerSize()[1]",i=`isFinite(${n}) ? ${n} : ${_s(e.config.view,t?"width":"height")}`;return[{name:r,init:i,on:[{update:i,events:"window:resize"}]}]}return[{name:r,value:i}]}function xf(e,t){const n=`${e}_step`;return yn(t.step)?{name:n,update:t.step.signal}:{name:n,value:t.step}}function $f(e,t,n){const i=t.get("type"),r=t.get("padding"),o=U(t.get("paddingOuter"),r);let a=t.get("paddingInner");return a="band"===i?void 0!==a?a:r:1,`bandspace(${n}, ${On(a)}, ${On(o)}) * ${e}_step`}function wf(e){return"childWidth"===e?"width":"childHeight"===e?"height":e}function kf(e,t){return D(e).reduce(((n,i)=>{const r=e[i];return{...n,...Ec(t,r,i,(e=>Fn(e.value)))}}),{})}function Sf(e,t){if(hm(t))return"theta"===e?"independent":"shared";if(vm(t))return"shared";if(ym(t))return zt(e)||"theta"===e||"radius"===e?"independent":"shared";throw new Error("invalid model type for resolve")}function Df(e,t){const n=e.scale[t],i=zt(t)?"axis":"legend";return"independent"===n?("shared"===e[i][t]&&yi(function(e){return`Setting the scale to be independent for "${e}" means we also have to set the guide (axis or legend) to be independent.`}(t)),"independent"):e[i][t]||"shared"}const Ff=D({aria:1,clipHeight:1,columnPadding:1,columns:1,cornerRadius:1,description:1,direction:1,fillColor:1,format:1,formatType:1,gradientLength:1,gradientOpacity:1,gradientStrokeColor:1,gradientStrokeWidth:1,gradientThickness:1,gridAlign:1,labelAlign:1,labelBaseline:1,labelColor:1,labelFont:1,labelFontSize:1,labelFontStyle:1,labelFontWeight:1,labelLimit:1,labelOffset:1,labelOpacity:1,labelOverlap:1,labelPadding:1,labelSeparation:1,legendX:1,legendY:1,offset:1,orient:1,padding:1,rowPadding:1,strokeColor:1,symbolDash:1,symbolDashOffset:1,symbolFillColor:1,symbolLimit:1,symbolOffset:1,symbolOpacity:1,symbolSize:1,symbolStrokeColor:1,symbolStrokeWidth:1,symbolType:1,tickCount:1,tickMinStep:1,title:1,titleAlign:1,titleAnchor:1,titleBaseline:1,titleColor:1,titleFont:1,titleFontSize:1,titleFontStyle:1,titleFontWeight:1,titleLimit:1,titleLineHeight:1,titleOpacity:1,titleOrient:1,titlePadding:1,type:1,values:1,zindex:1,disable:1,labelExpr:1,selections:1,opacity:1,shape:1,stroke:1,fill:1,size:1,strokeWidth:1,strokeDash:1,encode:1});class zf extends Gl{}const Of={symbols:function(e,n){let{fieldOrDatumDef:i,model:r,channel:o,legendCmpt:a,legendType:s}=n;if("symbol"!==s)return;const{markDef:l,encoding:c,config:u,mark:f}=r,d=l.filled&&"trail"!==f;let m={..._n({},r,ho),...Vc(r,{filled:d})};const p=a.get("symbolOpacity")??u.legend.symbolOpacity,g=a.get("symbolFillColor")??u.legend.symbolFillColor,h=a.get("symbolStrokeColor")??u.legend.symbolStrokeColor,y=void 0===p?_f(c.opacity)??l.opacity:void 0;if(m.fill)if("fill"===o||d&&o===me)delete m.fill;else if(m.fill.field)g?delete m.fill:(m.fill=Fn(u.legend.symbolBaseFillColor??"black"),m.fillOpacity=Fn(y??1));else if(t.isArray(m.fill)){const e=Cf(c.fill??c.color)??l.fill??(d&&l.color);e&&(m.fill=Fn(e))}if(m.stroke)if("stroke"===o||!d&&o===me)delete m.stroke;else if(m.stroke.field||h)delete m.stroke;else if(t.isArray(m.stroke)){const e=U(Cf(c.stroke||c.color),l.stroke,d?l.color:void 0);e&&(m.stroke={value:e})}if(o!==be){const e=Ro(i)&&Pf(r,a,i);e?m.opacity=[{test:e,...Fn(y??1)},Fn(u.legend.unselectedOpacity)]:y&&(m.opacity=Fn(y))}return m={...m,...e},S(m)?void 0:m},gradient:function(e,t){let{model:n,legendType:i,legendCmpt:r}=t;if("gradient"!==i)return;const{config:o,markDef:a,encoding:s}=n;let l={};const c=void 0===(r.get("gradientOpacity")??o.legend.gradientOpacity)?_f(s.opacity)||a.opacity:void 0;c&&(l.opacity=Fn(c));return l={...l,...e},S(l)?void 0:l},labels:function(e,t){let{fieldOrDatumDef:n,model:i,channel:r,legendCmpt:o}=t;const a=i.legend(r)||{},s=i.config,l=Ro(n)?Pf(i,o,n):void 0,c=l?[{test:l,value:1},{value:s.legend.unselectedOpacity}]:void 0,{format:u,formatType:f}=a;let d;Lr(f)?d=Br({fieldOrDatumDef:n,field:"datum.value",format:u,formatType:f,config:s}):void 0===u&&void 0===f&&s.customFormatTypes&&("quantitative"===n.type&&s.numberFormatType?d=Br({fieldOrDatumDef:n,field:"datum.value",format:s.numberFormat,formatType:s.numberFormatType,config:s}):"temporal"===n.type&&s.timeFormatType&&Ro(n)&&void 0===n.timeUnit&&(d=Br({fieldOrDatumDef:n,field:"datum.value",format:s.timeFormat,formatType:s.timeFormatType,config:s})));const m={...c?{opacity:c}:{},...d?{text:d}:{},...e};return S(m)?void 0:m},entries:function(e,t){let{legendCmpt:n}=t;const i=n.get("selections");return i?.length?{...e,fill:{value:"transparent"}}:e}};function _f(e){return Nf(e,((e,t)=>Math.max(e,t.value)))}function Cf(e){return Nf(e,((e,t)=>U(e,t.value)))}function Nf(e,n){return function(e){const n=e?.condition;return!!n&&(t.isArray(n)||Xo(n))}(e)?t.array(e.condition).reduce(n,e.value):Xo(e)?e.value:void 0}function Pf(e,n,i){const r=n.get("selections");if(!r?.length)return;const o=t.stringValue(i.field);return r.map((e=>`(!length(data(${t.stringValue(_(e)+Ou)})) || (${e}[${o}] && indexof(${e}[${o}], datum.value) >= 0))`)).join(" || ")}const Af={direction:e=>{let{direction:t}=e;return t},format:e=>{let{fieldOrDatumDef:t,legend:n,config:i}=e;const{format:r,formatType:o}=n;return Ir(t,t.type,r,o,i,!1)},formatType:e=>{let{legend:t,fieldOrDatumDef:n,scaleType:i}=e;const{formatType:r}=t;return Hr(r,n,i)},gradientLength:e=>{const{legend:t,legendConfig:n}=e;return t.gradientLength??n.gradientLength??function(e){let{legendConfig:t,model:n,direction:i,orient:r,scaleType:o}=e;const{gradientHorizontalMaxLength:a,gradientHorizontalMinLength:s,gradientVerticalMaxLength:l,gradientVerticalMinLength:c}=t;if(vr(o))return"horizontal"===i?"top"===r||"bottom"===r?Ef(n,"width",s,a):s:Ef(n,"height",c,l);return}(e)},labelOverlap:e=>{let{legend:t,legendConfig:n,scaleType:i}=e;return t.labelOverlap??n.labelOverlap??function(e){if(p(["quantile","threshold","log","symlog"],e))return"greedy";return}(i)},symbolType:e=>{let{legend:t,markDef:n,channel:i,encoding:r}=e;return t.symbolType??function(e,t,n,i){if("shape"!==t){const e=Cf(n)??i;if(e)return e}switch(e){case"bar":case"rect":case"image":case"square":return"square";case"line":case"trail":case"rule":return"stroke";case"arc":case"point":case"circle":case"tick":case"geoshape":case"area":case"text":return"circle"}}(n.type,i,r.shape,n.shape)},title:e=>{let{fieldOrDatumDef:t,config:n}=e;return aa(t,n,{allowDisabling:!0})},type:e=>{let{legendType:t,scaleType:n,channel:i}=e;if(qe(i)&&vr(n)){if("gradient"===t)return}else if("symbol"===t)return;return t},values:e=>{let{fieldOrDatumDef:n,legend:i}=e;return function(e,n){const i=e.values;if(t.isArray(i))return ba(n,i);if(yn(i))return i;return}(i,n)}};function jf(e){const{legend:t}=e;return U(t.type,function(e){let{channel:t,timeUnit:n,scaleType:i}=e;if(qe(t)){if(p(["quarter","month","day"],n))return"symbol";if(vr(i))return"gradient"}return"symbol"}(e))}function Tf(e){let{legendConfig:t,legendType:n,orient:i,legend:r}=e;return r.direction??t[n?"gradientDirection":"symbolDirection"]??function(e,t){switch(e){case"top":case"bottom":return"horizontal";case"left":case"right":case"none":case void 0:return;default:return"gradient"===t?"horizontal":void 0}}(i,n)}function Ef(e,t,n,i){return{signal:`clamp(${e.getSizeSignalRef(t).signal}, ${n}, ${i})`}}function Mf(e){const t=gm(e)?function(e){const{encoding:t}=e,n={};for(const i of[me,...gs]){const r=fa(t[i]);r&&e.getScaleComponent(i)&&(i===he&&Ro(r)&&r.type===rr||(n[i]=qf(e,i)))}return n}(e):function(e){const{legends:t,resolve:n}=e.component;for(const i of e.children){Mf(i);for(const r of D(i.component.legends))n.legend[r]=Df(e.component.resolve,r),"shared"===n.legend[r]&&(t[r]=Uf(t[r],i.component.legends[r]),t[r]||(n.legend[r]="independent",delete t[r]))}for(const i of D(t))for(const t of e.children)t.component.legends[i]&&"shared"===n.legend[i]&&delete t.component.legends[i];return t}(e);return e.component.legends=t,t}function Lf(e,t,n,i){switch(t){case"disable":return void 0!==n;case"values":return!!n?.values;case"title":if("title"===t&&e===i?.title)return!0}return e===(n||{})[t]}function qf(e,t){let n=e.legend(t);const{markDef:i,encoding:r,config:o}=e,a=o.legend,s=new zf({},function(e,t){const n=e.scaleName(t);if("trail"===e.mark){if("color"===t)return{stroke:n};if("size"===t)return{strokeWidth:n}}return"color"===t?e.markDef.filled?{fill:n}:{stroke:n}:{[t]:n}}(e,t));!function(e,t,n){const i=e.fieldDef(t)?.field;for(const r of F(e.component.selection??{})){const e=r.project.hasField[i]??r.project.hasChannel[t];if(e&&bu.defined(r)){const t=n.get("selections")??[];t.push(r.name),n.set("selections",t,!1),e.hasLegend=!0}}}(e,t,s);const l=void 0!==n?!n:a.disable;if(s.set("disable",l,void 0!==n),l)return s;n=n||{};const c=e.getScaleComponent(t).get("type"),u=fa(r[t]),f=Ro(u)?Ei(u.timeUnit)?.unit:void 0,d=n.orient||o.legend.orient||"right",m=jf({legend:n,channel:t,timeUnit:f,scaleType:c}),p={legend:n,channel:t,model:e,markDef:i,encoding:r,fieldOrDatumDef:u,legendConfig:a,config:o,scaleType:c,orient:d,legendType:m,direction:Tf({legend:n,legendType:m,orient:d,legendConfig:a})};for(const i of Ff){if("gradient"===m&&i.startsWith("symbol")||"symbol"===m&&i.startsWith("gradient"))continue;const r=i in Af?Af[i](p):n[i];if(void 0!==r){const a=Lf(r,i,n,e.fieldDef(t));(a||void 0===o.legend[i])&&s.set(i,r,a)}}const g=n?.encoding??{},h=s.get("selections"),y={},v={fieldOrDatumDef:u,model:e,channel:t,legendCmpt:s,legendType:m};for(const t of["labels","legend","title","symbols","gradient","entries"]){const n=kf(g[t]??{},e),i=t in Of?Of[t](n,v):n;void 0===i||S(i)||(y[t]={...h?.length&&Ro(u)?{name:`${_(u.field)}_legend_${t}`}:{},...h?.length?{interactive:!!h}:{},update:i})}return S(y)||s.set("encode",y,!!n?.encoding),s}function Uf(e,t){if(!e)return t.clone();const n=e.getWithExplicit("orient"),i=t.getWithExplicit("orient");if(n.explicit&&i.explicit&&n.value!==i.value)return;let r=!1;for(const n of Ff){const i=Kl(e.getWithExplicit(n),t.getWithExplicit(n),n,"legend",((e,t)=>{switch(n){case"symbolType":return Rf(e,t);case"title":return Ln(e,t);case"type":return r=!0,Xl("symbol")}return Jl(e,t,n,"legend")}));e.setWithExplicit(n,i)}return r&&(e.implicit?.encode?.gradient&&N(e.implicit,["encode","gradient"]),e.explicit?.encode?.gradient&&N(e.explicit,["encode","gradient"])),e}function Rf(e,t){return"circle"===t.value?t:e}function Wf(e){const t=e.component.legends,n={};for(const i of D(t)){const r=X(e.getScaleComponent(i).get("domains"));if(n[r])for(const e of n[r]){Uf(e,t[i])||n[r].push(t[i])}else n[r]=[t[i].clone()]}return F(n).flat().map((t=>function(e,t){const{disable:n,labelExpr:i,selections:r,...o}=e.combine();if(n)return;!1===t.aria&&null==o.aria&&(o.aria=!1);if(o.encode?.symbols){const e=o.encode.symbols.update;!e.fill||"transparent"===e.fill.value||e.stroke||o.stroke||(e.stroke={value:"transparent"});for(const t of gs)o[t]&&delete e[t]}o.title||delete o.title;if(void 0!==i){let e=i;o.encode?.labels?.update&&yn(o.encode.labels.update.text)&&(e=M(i,"datum.label",o.encode.labels.update.text.signal)),function(e,t,n,i){e.encode??={},e.encode[t]??={},e.encode[t].update??={},e.encode[t].update[n]=i}(o,"labels","text",{signal:e})}return o}(t,e.config))).filter((e=>void 0!==e))}function Bf(e){return vm(e)||ym(e)?function(e){return e.children.reduce(((e,t)=>e.concat(t.assembleProjections())),If(e))}(e):If(e)}function If(e){const t=e.component.projection;if(!t||t.merged)return[];const n=t.combine(),{name:i}=n;if(t.data){const r={signal:`[${t.size.map((e=>e.signal)).join(", ")}]`},o=t.data.reduce(((t,n)=>{const i=yn(n)?n.signal:`data('${e.lookupDataSource(n)}')`;return p(t,i)||t.push(i),t}),[]);if(o.length<=0)throw new Error("Projection's fit didn't find any data sources");return[{name:i,size:r,fit:{signal:o.length>1?`[${o.join(", ")}]`:o[0]},...n}]}return[{name:i,translate:{signal:"[width / 2, height / 2]"},...n}]}const Hf=["type","clipAngle","clipExtent","center","rotate","precision","reflectX","reflectY","coefficient","distance","fraction","lobes","parallel","radius","ratio","spacing","tilt"];class Vf extends Gl{merged=!1;constructor(e,t,n,i){super({...t},{name:e}),this.specifiedProjection=t,this.size=n,this.data=i}get isFit(){return!!this.data}}function Gf(e){e.component.projection=gm(e)?function(e){if(e.hasProjection){const t=pn(e.specifiedProjection),n=!(t&&(null!=t.scale||null!=t.translate)),i=n?[e.getSizeSignalRef("width"),e.getSizeSignalRef("height")]:void 0,r=n?function(e){const t=[],{encoding:n}=e;for(const i of[[ue,ce],[de,fe]])(fa(n[i[0]])||fa(n[i[1]]))&&t.push({signal:e.getName(`geojson_${t.length}`)});e.channelHasField(he)&&e.typedFieldDef(he).type===rr&&t.push({signal:e.getName(`geojson_${t.length}`)});0===t.length&&t.push(e.requestDataName(sc.Main));return t}(e):void 0,o=new Vf(e.projectionName(!0),{...pn(e.config.projection),...t},i,r);return o.get("type")||o.set("type","equalEarth",!1),o}return}(e):function(e){if(0===e.children.length)return;let n;for(const t of e.children)Gf(t);const i=h(e.children,(e=>{const i=e.component.projection;if(i){if(n){const e=function(e,n){const i=h(Hf,(i=>!t.hasOwnProperty(e.explicit,i)&&!t.hasOwnProperty(n.explicit,i)||!!(t.hasOwnProperty(e.explicit,i)&&t.hasOwnProperty(n.explicit,i)&&Y(e.get(i),n.get(i)))));if(Y(e.size,n.size)){if(i)return e;if(Y(e.explicit,{}))return n;if(Y(n.explicit,{}))return e}return null}(n,i);return e&&(n=e),!!e}return n=i,!0}return!0}));if(n&&i){const t=e.projectionName(!0),i=new Vf(t,n.specifiedProjection,n.size,l(n.data));for(const n of e.children){const e=n.component.projection;e&&(e.isFit&&i.data.push(...n.component.projection.data),n.renameProjection(e.get("name"),t),e.merged=!0)}return i}return}(e)}function Yf(e,t,n,i){if(xa(t,n)){const r=gm(e)?e.axis(n)??e.legend(n)??{}:{},o=ta(t,{expr:"datum"}),a=ta(t,{expr:"datum",binSuffix:"end"});return{formulaAs:ta(t,{binSuffix:"range",forAs:!0}),formula:Xr(o,a,r.format,r.formatType,i)}}return{}}function Xf(e,t){return`${sn(e)}_${t}`}function Qf(e,t,n){const i=Xf(ga(n,void 0)??{},t);return e.getName(`${i}_bins`)}function Jf(e,n,i){let r,o;r=function(e){return"as"in e}(e)?t.isString(e.as)?[e.as,`${e.as}_end`]:[e.as[0],e.as[1]]:[ta(e,{forAs:!0}),ta(e,{binSuffix:"end",forAs:!0})];const a={...ga(n,void 0)},s=Xf(a,e.field),{signal:l,extentSignal:c}=function(e,t){return{signal:e.getName(`${t}_bins`),extentSignal:e.getName(`${t}_extent`)}}(i,s);if(fn(a.extent)){const e=a.extent;o=Ru(i,e.param,e),delete a.extent}return{key:s,binComponent:{bin:a,field:e.field,as:[r],...l?{signal:l}:{},...c?{extentSignal:c}:{},...o?{span:o}:{}}}}class Kf extends pc{clone(){return new Kf(null,l(this.bins))}constructor(e,t){super(e),this.bins=t}static makeFromEncoding(e,t){const n=t.reduceFieldDef(((e,n,i)=>{if(Yo(n)&&ln(n.bin)){const{key:r,binComponent:o}=Jf(n,n.bin,t);e[r]={...o,...e[r],...Yf(t,n,i,t.config)}}return e}),{});return S(n)?null:new Kf(e,n)}static makeFromTransform(e,t,n){const{key:i,binComponent:r}=Jf(t,t.bin,n);return new Kf(e,{[i]:r})}merge(e,t){for(const n of D(e.bins))n in this.bins?(t(e.bins[n].signal,this.bins[n].signal),this.bins[n].as=b([...this.bins[n].as,...e.bins[n].as],d)):this.bins[n]=e.bins[n];for(const t of e.children)e.removeChild(t),t.parent=this;e.remove()}producedFields(){return new Set(F(this.bins).map((e=>e.as)).flat(2))}dependentFields(){return new Set(F(this.bins).map((e=>e.field)))}hash(){return`Bin ${d(this.bins)}`}assemble(){return F(this.bins).flatMap((e=>{const t=[],[n,...i]=e.as,{extent:r,...o}=e.bin,a={type:"bin",field:E(e.field),as:n,signal:e.signal,...fn(r)?{extent:null}:{extent:r},...e.span?{span:{signal:`span(${e.span})`}}:{},...o};!r&&e.extentSignal&&(t.push({type:"extent",field:E(e.field),signal:e.extentSignal}),a.extent={signal:e.extentSignal}),t.push(a);for(const e of i)for(let i=0;i<2;i++)t.push({type:"formula",expr:ta({field:n[i]},{expr:"datum"}),as:e[i]});return e.formula&&t.push({type:"formula",expr:e.formula,as:e.formulaAs}),t}))}}function Zf(e,n,i,r){const o=gm(r)?r.encoding[it(n)]:void 0;if(Yo(i)&&gm(r)&&Eo(i,o,r.markDef,r.config)){e.add(ta(i,{})),e.add(ta(i,{suffix:"end"}));const{mark:t,markDef:o,config:a}=r,s=jo({fieldDef:i,markDef:o,config:a});mo(t)&&.5!==s&&zt(n)&&(e.add(ta(i,{suffix:bc})),e.add(ta(i,{suffix:xc}))),i.bin&&xa(i,n)&&e.add(ta(i,{binSuffix:"range"}))}else if(Ee(n)){const t=Te(n);e.add(r.getName(t))}else e.add(ta(i));return Qo(i)&&function(e){return t.isObject(e)&&"field"in e}(i.scale?.range)&&e.add(i.scale.range.field),e}class ed extends pc{clone(){return new ed(null,new Set(this.dimensions),l(this.measures))}constructor(e,t,n){super(e),this.dimensions=t,this.measures=n}get groupBy(){return this.dimensions}static makeFromEncoding(e,t){let n=!1;t.forEachFieldDef((e=>{e.aggregate&&(n=!0)}));const i={},r=new Set;return n?(t.forEachFieldDef(((e,n)=>{const{aggregate:o,field:a}=e;if(o)if("count"===o)i["*"]??={},i["*"].count=new Set([ta(e,{forAs:!0})]);else{if(Zt(o)||en(o)){const e=Zt(o)?"argmin":"argmax",t=o[e];i[t]??={},i[t][e]=new Set([ta({op:e,field:t},{forAs:!0})])}else i[a]??={},i[a][o]=new Set([ta(e,{forAs:!0})]);Ht(n)&&"unaggregated"===t.scaleDomain(n)&&(i[a]??={},i[a].min=new Set([ta({field:a,aggregate:"min"},{forAs:!0})]),i[a].max=new Set([ta({field:a,aggregate:"max"},{forAs:!0})]))}else Zf(r,n,e,t)})),r.size+D(i).length===0?null:new ed(e,r,i)):null}static makeFromTransform(e,t){const n=new Set,i={};for(const e of t.aggregate){const{op:t,field:n,as:r}=e;t&&("count"===t?(i["*"]??={},i["*"].count=new Set([r||ta(e,{forAs:!0})])):(i[n]??={},i[n][t]=new Set([r||ta(e,{forAs:!0})])))}for(const e of t.groupby??[])n.add(e);return n.size+D(i).length===0?null:new ed(e,n,i)}merge(e){return x(this.dimensions,e.dimensions)?(function(e,t){for(const n of D(t)){const i=t[n];for(const t of D(i))n in e?e[n][t]=new Set([...e[n][t]??[],...i[t]]):e[n]={[t]:i[t]}}}(this.measures,e.measures),!0):(function(){hi.debug(...arguments)}("different dimensions, cannot merge"),!1)}addDimensions(e){e.forEach(this.dimensions.add,this.dimensions)}dependentFields(){return new Set([...this.dimensions,...D(this.measures)])}producedFields(){const e=new Set;for(const t of D(this.measures))for(const n of D(this.measures[t])){const i=this.measures[t][n];0===i.size?e.add(`${n}_${t}`):i.forEach(e.add,e)}return e}hash(){return`Aggregate ${d({dimensions:this.dimensions,measures:this.measures})}`}assemble(){const e=[],t=[],n=[];for(const i of D(this.measures))for(const r of D(this.measures[i]))for(const o of this.measures[i][r])n.push(o),e.push(r),t.push("*"===i?null:E(i));return{type:"aggregate",groupby:[...this.dimensions].map(E),ops:e,fields:t,as:n}}}class td extends pc{constructor(e,n,i,r){super(e),this.model=n,this.name=i,this.data=r;for(const e of Re){const i=n.facet[e];if(i){const{bin:r,sort:o}=i;this[e]={name:n.getName(`${e}_domain`),fields:[ta(i),...ln(r)?[ta(i,{binSuffix:"end"})]:[]],...zo(o)?{sortField:o}:t.isArray(o)?{sortIndexField:tf(i,e)}:{}}}}this.childModel=n.child}hash(){let e="Facet";for(const t of Re)this[t]&&(e+=` ${t.charAt(0)}:${d(this[t])}`);return e}get fields(){const e=[];for(const t of Re)this[t]?.fields&&e.push(...this[t].fields);return e}dependentFields(){const e=new Set(this.fields);for(const t of Re)this[t]&&(this[t].sortField&&e.add(this[t].sortField.field),this[t].sortIndexField&&e.add(this[t].sortIndexField));return e}producedFields(){return new Set}getSource(){return this.name}getChildIndependentFieldsWithStep(){const e={};for(const t of Ft){const n=this.childModel.component.scales[t];if(n&&!n.merged){const i=n.get("type"),r=n.get("range");if(hr(i)&&vn(r)){const n=Hd(Vd(this.childModel,t));n?e[t]=n:yi(In(t))}}}return e}assembleRowColumnHeaderData(e,t,n){const i={row:"y",column:"x",facet:void 0}[e],r=[],o=[],a=[];i&&n&&n[i]&&(t?(r.push(`distinct_${n[i]}`),o.push("max")):(r.push(n[i]),o.push("distinct")),a.push(`distinct_${n[i]}`));const{sortField:s,sortIndexField:l}=this[e];if(s){const{op:e=ko,field:t}=s;r.push(t),o.push(e),a.push(ta(s,{forAs:!0}))}else l&&(r.push(l),o.push("max"),a.push(l));return{name:this[e].name,source:t??this.data,transform:[{type:"aggregate",groupby:this[e].fields,...r.length?{fields:r,ops:o,as:a}:{}}]}}assembleFacetHeaderData(e){const{columns:t}=this.model.layout,{layoutHeaders:n}=this.model.component,i=[],r={};for(const e of af){for(const t of sf){const i=(n[e]&&n[e][t])??[];for(const t of i)if(t.axes?.length>0){r[e]=!0;break}}if(r[e]){const n=`length(data("${this.facet.name}"))`,r="row"===e?t?{signal:`ceil(${n} / ${t})`}:1:t?{signal:`min(${n}, ${t})`}:{signal:n};i.push({name:`${this.facet.name}_${e}`,transform:[{type:"sequence",start:0,stop:r}]})}}const{row:o,column:a}=r;return(o||a)&&i.unshift(this.assembleRowColumnHeaderData("facet",null,e)),i}assemble(){const e=[];let t=null;const n=this.getChildIndependentFieldsWithStep(),{column:i,row:r,facet:o}=this;if(i&&r&&(n.x||n.y)){t=`cross_${this.column.name}_${this.row.name}`;const i=[].concat(n.x??[],n.y??[]),r=i.map((()=>"distinct"));e.push({name:t,source:this.data,transform:[{type:"aggregate",groupby:this.fields,fields:i,ops:r}]})}for(const i of[J,Q])this[i]&&e.push(this.assembleRowColumnHeaderData(i,t,n));if(o){const t=this.assembleFacetHeaderData(n);t&&e.push(...t)}return e}}function nd(e){return e.startsWith("'")&&e.endsWith("'")||e.startsWith('"')&&e.endsWith('"')?e.slice(1,-1):e}function id(e){const n={};return a(e.filter,(e=>{if(Gi(e)){let i=null;Ui(e)?i=Sn(e.equal):Wi(e)?i=Sn(e.lte):Ri(e)?i=Sn(e.lt):Bi(e)?i=Sn(e.gt):Ii(e)?i=Sn(e.gte):Hi(e)?i=e.range[0]:Vi(e)&&(i=(e.oneOf??e.in)[0]),i&&(vi(i)?n[e.field]="date":t.isNumber(i)?n[e.field]="number":t.isString(i)&&(n[e.field]="string")),e.timeUnit&&(n[e.field]="date")}})),n}function rd(e){const n={};function i(e){var i;ya(e)?n[e.field]="date":"quantitative"===e.type&&(i=e.aggregate,t.isString(i)&&p(["min","max"],i))?n[e.field]="number":q(e.field)>1?e.field in n||(n[e.field]="flatten"):Qo(e)&&zo(e.sort)&&q(e.sort.field)>1&&(e.sort.field in n||(n[e.sort.field]="flatten"))}if((gm(e)||hm(e))&&e.forEachFieldDef(((t,n)=>{if(Yo(t))i(t);else{const r=tt(n),o=e.fieldDef(r);i({...t,type:o.type})}})),gm(e)){const{mark:t,markDef:i,encoding:r}=e;if(fo(t)&&!e.encoding.order){const e=r["horizontal"===i.orient?"y":"x"];Ro(e)&&"quantitative"===e.type&&!(e.field in n)&&(n[e.field]="number")}}return n}class od extends pc{clone(){return new od(null,l(this._parse))}constructor(e,t){super(e),this._parse=t}hash(){return`Parse ${d(this._parse)}`}static makeExplicit(e,t,n){let i={};const r=t.data;return!ic(r)&&r?.format?.parse&&(i=r.format.parse),this.makeWithAncestors(e,i,{},n)}static makeWithAncestors(e,t,n,i){for(const e of D(n)){const t=i.getWithExplicit(e);void 0!==t.value&&(t.explicit||t.value===n[e]||"derived"===t.value||"flatten"===n[e]?delete n[e]:yi(Qn(e,n[e],t.value)))}for(const e of D(t)){const n=i.get(e);void 0!==n&&(n===t[e]?delete t[e]:yi(Qn(e,t[e],n)))}const r=new Gl(t,n);i.copyAll(r);const o={};for(const e of D(r.combine())){const t=r.get(e);null!==t&&(o[e]=t)}return 0===D(o).length||i.parseNothing?null:new od(e,o)}get parse(){return this._parse}merge(e){this._parse={...this._parse,...e.parse},e.remove()}assembleFormatParse(){const e={};for(const t of D(this._parse)){const n=this._parse[t];1===q(t)&&(e[t]=n)}return e}producedFields(){return new Set(D(this._parse))}dependentFields(){return new Set(D(this._parse))}assembleTransforms(){let e=arguments.length>0&&void 0!==arguments[0]&&arguments[0];return D(this._parse).filter((t=>!e||q(t)>1)).map((e=>{const t=function(e,t){const n=A(e);if("number"===t)return`toNumber(${n})`;if("boolean"===t)return`toBoolean(${n})`;if("string"===t)return`toString(${n})`;if("date"===t)return`toDate(${n})`;if("flatten"===t)return n;if(t.startsWith("date:"))return`timeParse(${n},'${nd(t.slice(5,t.length))}')`;if(t.startsWith("utc:"))return`utcParse(${n},'${nd(t.slice(4,t.length))}')`;return yi(`Unrecognized parse "${t}".`),null}(e,this._parse[e]);if(!t)return null;return{type:"formula",expr:t,as:L(e)}})).filter((e=>null!==e))}}class ad extends pc{clone(){return new ad(null)}constructor(e){super(e)}dependentFields(){return new Set}producedFields(){return new Set([hs])}hash(){return"Identifier"}assemble(){return{type:"identifier",as:hs}}}class sd extends pc{clone(){return new sd(null,this.params)}constructor(e,t){super(e),this.params=t}dependentFields(){return new Set}producedFields(){}hash(){return`Graticule ${d(this.params)}`}assemble(){return{type:"graticule",...!0===this.params?{}:this.params}}}class ld extends pc{clone(){return new ld(null,this.params)}constructor(e,t){super(e),this.params=t}dependentFields(){return new Set}producedFields(){return new Set([this.params.as??"data"])}hash(){return`Hash ${d(this.params)}`}assemble(){return{type:"sequence",...this.params}}}class cd extends pc{constructor(e){let t;if(super(null),e??={name:"source"},ic(e)||(t=e.format?{...f(e.format,["parse"])}:{}),tc(e))this._data={values:e.values};else if(ec(e)){if(this._data={url:e.url},!t.type){let n=/(?:\.([^.]+))?$/.exec(e.url)[1];p(["json","csv","tsv","dsv","topojson"],n)||(n="json"),t.type=n}}else oc(e)?this._data={values:[{type:"Sphere"}]}:(nc(e)||ic(e))&&(this._data={});this._generator=ic(e),e.name&&(this._name=e.name),t&&!S(t)&&(this._data.format=t)}dependentFields(){return new Set}producedFields(){}get data(){return this._data}hasName(){return!!this._name}get isGenerator(){return this._generator}get dataName(){return this._name}set dataName(e){this._name=e}set parent(e){throw new Error("Source nodes have to be roots.")}remove(){throw new Error("Source nodes are roots and cannot be removed.")}hash(){throw new Error("Cannot hash sources")}assemble(){return{name:this._name,...this._data,transform:[]}}}function ud(e){return e instanceof cd||e instanceof sd||e instanceof ld}class fd{#e;constructor(){this.#e=!1}setModified(){this.#e=!0}get modifiedFlag(){return this.#e}}class dd extends fd{getNodeDepths(e,t,n){n.set(e,t);for(const i of e.children)this.getNodeDepths(i,t+1,n);return n}optimize(e){const t=[...this.getNodeDepths(e,0,new Map).entries()].sort(((e,t)=>t[1]-e[1]));for(const e of t)this.run(e[0]);return this.modifiedFlag}}class md extends fd{optimize(e){this.run(e);for(const t of e.children)this.optimize(t);return this.modifiedFlag}}class pd extends md{mergeNodes(e,t){const n=t.shift();for(const i of t)e.removeChild(i),i.parent=n,i.remove()}run(e){const t=e.children.map((e=>e.hash())),n={};for(let i=0;i1&&(this.setModified(),this.mergeNodes(e,n[t]))}}class gd extends md{constructor(e){super(),this.requiresSelectionId=e&&ju(e)}run(e){e instanceof ad&&(this.requiresSelectionId&&(ud(e.parent)||e.parent instanceof ed||e.parent instanceof od)||(this.setModified(),e.remove()))}}class hd extends fd{optimize(e){return this.run(e,new Set),this.modifiedFlag}run(e,t){let n=new Set;e instanceof vc&&(n=e.producedFields(),$(n,t)&&(this.setModified(),e.removeFormulas(t),0===e.producedFields.length&&e.remove()));for(const i of e.children)this.run(i,new Set([...t,...n]))}}class yd extends md{constructor(){super()}run(e){e instanceof gc&&!e.isRequired()&&(this.setModified(),e.remove())}}class vd extends dd{run(e){if(!(ud(e)||e.numChildren()>1))for(const t of e.children)if(t instanceof od)if(e instanceof od)this.setModified(),e.merge(t);else{if(k(e.producedFields(),t.dependentFields()))continue;this.setModified(),t.swapWithParent()}}}class bd extends dd{run(e){const t=[...e.children],n=e.children.filter((e=>e instanceof od));if(e.numChildren()>1&&n.length>=1){const i={},r=new Set;for(const e of n){const t=e.parse;for(const e of D(t))e in i?i[e]!==t[e]&&r.add(e):i[e]=t[e]}for(const e of r)delete i[e];if(!S(i)){this.setModified();const n=new od(e,i);for(const r of t){if(r instanceof od)for(const e of D(i))delete r.parse[e];e.removeChild(r),r.parent=n,r instanceof od&&0===D(r.parse).length&&r.remove()}}}}}class xd extends dd{run(e){e instanceof gc||e.numChildren()>0||e instanceof td||e instanceof cd||(this.setModified(),e.remove())}}class $d extends dd{run(e){const t=e.children.filter((e=>e instanceof vc)),n=t.pop();for(const e of t)this.setModified(),n.merge(e)}}class wd extends dd{run(e){const t=e.children.filter((e=>e instanceof ed)),n={};for(const e of t){const t=d(e.groupBy);t in n||(n[t]=[]),n[t].push(e)}for(const t of D(n)){const i=n[t];if(i.length>1){const t=i.pop();for(const n of i)t.merge(n)&&(e.removeChild(n),n.parent=t,n.remove(),this.setModified())}}}}class kd extends dd{constructor(e){super(),this.model=e}run(e){const t=!(ud(e)||e instanceof qu||e instanceof od||e instanceof ad),n=[],i=[];for(const r of e.children)r instanceof Kf&&(t&&!k(e.producedFields(),r.dependentFields())?n.push(r):i.push(r));if(n.length>0){const t=n.pop();for(const e of n)t.merge(e,this.model.renameSignal.bind(this.model));this.setModified(),e instanceof Kf?e.merge(t,this.model.renameSignal.bind(this.model)):t.swapWithParent()}if(i.length>1){const e=i.pop();for(const t of i)e.merge(t,this.model.renameSignal.bind(this.model));this.setModified()}}}class Sd extends dd{run(e){const t=[...e.children];if(!g(t,(e=>e instanceof gc))||e.numChildren()<=1)return;const n=[];let i;for(const r of t)if(r instanceof gc){let t=r;for(;1===t.numChildren();){const[e]=t.children;if(!(e instanceof gc))break;t=e}n.push(...t.children),i?(e.removeChild(r),r.parent=i.parent,i.parent.removeChild(i),i.parent=t,this.setModified()):i=t}else n.push(r);if(n.length){this.setModified();for(const e of n)e.parent.removeChild(e),e.parent=i}}}class Dd extends pc{clone(){return new Dd(null,l(this.transform))}constructor(e,t){super(e),this.transform=t}addDimensions(e){this.transform.groupby=b(this.transform.groupby.concat(e),(e=>e))}dependentFields(){const e=new Set;return this.transform.groupby&&this.transform.groupby.forEach(e.add,e),this.transform.joinaggregate.map((e=>e.field)).filter((e=>void 0!==e)).forEach(e.add,e),e}producedFields(){return new Set(this.transform.joinaggregate.map(this.getDefaultName))}getDefaultName(e){return e.as??ta(e)}hash(){return`JoinAggregateTransform ${d(this.transform)}`}assemble(){const e=[],t=[],n=[];for(const i of this.transform.joinaggregate)t.push(i.op),n.push(this.getDefaultName(i)),e.push(void 0===i.field?null:i.field);const i=this.transform.groupby;return{type:"joinaggregate",as:n,ops:t,fields:e,...void 0!==i?{groupby:i}:{}}}}class Fd extends pc{clone(){return new Fd(null,l(this._stack))}constructor(e,t){super(e),this._stack=t}static makeFromTransform(e,n){const{stack:i,groupby:r,as:o,offset:a="zero"}=n,s=[],l=[];if(void 0!==n.sort)for(const e of n.sort)s.push(e.field),l.push(U(e.order,"ascending"));const c={field:s,order:l};let u;return u=function(e){return t.isArray(e)&&e.every((e=>t.isString(e)))&&e.length>1}(o)?o:t.isString(o)?[o,`${o}_end`]:[`${n.stack}_start`,`${n.stack}_end`],new Fd(e,{dimensionFieldDefs:[],stackField:i,groupby:r,offset:a,sort:c,facetby:[],as:u})}static makeFromEncoding(e,n){const i=n.stack,{encoding:r}=n;if(!i)return null;const{groupbyChannels:o,fieldChannel:a,offset:s,impute:l}=i,c=o.map((e=>ua(r[e]))).filter((e=>!!e)),u=function(e){return e.stack.stackBy.reduce(((e,t)=>{const n=ta(t.fieldDef);return n&&e.push(n),e}),[])}(n),f=n.encoding.order;let d;if(t.isArray(f)||Ro(f))d=Tn(f);else{const e=Mo(f)?f.sort:"y"===a?"descending":"ascending";d=u.reduce(((t,n)=>(t.field.includes(n)||(t.field.push(n),t.order.push(e)),t)),{field:[],order:[]})}return new Fd(e,{dimensionFieldDefs:c,stackField:n.vgField(a),facetby:[],stackby:u,sort:d,offset:s,impute:l,as:[n.vgField(a,{suffix:"start",forAs:!0}),n.vgField(a,{suffix:"end",forAs:!0})]})}get stack(){return this._stack}addDimensions(e){this._stack.facetby.push(...e)}dependentFields(){const e=new Set;return e.add(this._stack.stackField),this.getGroupbyFields().forEach(e.add,e),this._stack.facetby.forEach(e.add,e),this._stack.sort.field.forEach(e.add,e),e}producedFields(){return new Set(this._stack.as)}hash(){return`Stack ${d(this._stack)}`}getGroupbyFields(){const{dimensionFieldDefs:e,impute:t,groupby:n}=this._stack;return e.length>0?e.map((e=>e.bin?t?[ta(e,{binSuffix:"mid"})]:[ta(e,{}),ta(e,{binSuffix:"end"})]:[ta(e)])).flat():n??[]}assemble(){const e=[],{facetby:t,dimensionFieldDefs:n,stackField:i,stackby:r,sort:o,offset:a,impute:s,as:l}=this._stack;if(s)for(const o of n){const{bandPosition:n=.5,bin:a}=o;if(a){const t=ta(o,{expr:"datum"}),i=ta(o,{expr:"datum",binSuffix:"end"});e.push({type:"formula",expr:`${n}*${t}+${1-n}*${i}`,as:ta(o,{binSuffix:"mid",forAs:!0})})}e.push({type:"impute",field:i,groupby:[...r,...t],key:ta(o,{binSuffix:"mid"}),method:"value",value:0})}return e.push({type:"stack",groupby:[...this.getGroupbyFields(),...t],field:i,sort:o,as:l,offset:a}),e}}class zd extends pc{clone(){return new zd(null,l(this.transform))}constructor(e,t){super(e),this.transform=t}addDimensions(e){this.transform.groupby=b(this.transform.groupby.concat(e),(e=>e))}dependentFields(){const e=new Set;return(this.transform.groupby??[]).forEach(e.add,e),(this.transform.sort??[]).forEach((t=>e.add(t.field))),this.transform.window.map((e=>e.field)).filter((e=>void 0!==e)).forEach(e.add,e),e}producedFields(){return new Set(this.transform.window.map(this.getDefaultName))}getDefaultName(e){return e.as??ta(e)}hash(){return`WindowTransform ${d(this.transform)}`}assemble(){const e=[],t=[],n=[],i=[];for(const r of this.transform.window)t.push(r.op),n.push(this.getDefaultName(r)),i.push(void 0===r.param?null:r.param),e.push(void 0===r.field?null:r.field);const r=this.transform.frame,o=this.transform.groupby;if(r&&null===r[0]&&null===r[1]&&t.every((e=>tn(e))))return{type:"joinaggregate",as:n,ops:t,fields:e,...void 0!==o?{groupby:o}:{}};const a=[],s=[];if(void 0!==this.transform.sort)for(const e of this.transform.sort)a.push(e.field),s.push(e.order??"ascending");const l={field:a,order:s},c=this.transform.ignorePeers;return{type:"window",params:i,as:n,ops:t,fields:e,sort:l,...void 0!==c?{ignorePeers:c}:{},...void 0!==o?{groupby:o}:{},...void 0!==r?{frame:r}:{}}}}function Od(e){if(e instanceof td)if(1!==e.numChildren()||e.children[0]instanceof gc){const n=e.model.component.data.main;_d(n);const i=(t=e,function e(n){if(!(n instanceof td)){const i=n.clone();if(i instanceof gc){const e=Cd+i.getSource();i.setSource(e),t.model.component.data.outputNodes[e]=i}else(i instanceof ed||i instanceof Fd||i instanceof zd||i instanceof Dd)&&i.addDimensions(t.fields);for(const t of n.children.flatMap(e))t.parent=i;return[i]}return n.children.flatMap(e)}),r=e.children.map(i).flat();for(const e of r)e.parent=n}else{const t=e.children[0];(t instanceof ed||t instanceof Fd||t instanceof zd||t instanceof Dd)&&t.addDimensions(e.fields),t.swapWithParent(),Od(e)}else e.children.map(Od);var t}function _d(e){if(e instanceof gc&&e.type===sc.Main&&1===e.numChildren()){const t=e.children[0];t instanceof td||(t.swapWithParent(),_d(e))}}const Cd="scale_",Nd=5;function Pd(e){for(const t of e){for(const e of t.children)if(e.parent!==t)return!1;if(!Pd(t.children))return!1}return!0}function Ad(e,t){let n=!1;for(const i of t)n=e.optimize(i)||n;return n}function jd(e,t,n){let i=e.sources,r=!1;return r=Ad(new yd,i)||r,r=Ad(new gd(t),i)||r,i=i.filter((e=>e.numChildren()>0)),r=Ad(new xd,i)||r,i=i.filter((e=>e.numChildren()>0)),n||(r=Ad(new vd,i)||r,r=Ad(new kd(t),i)||r,r=Ad(new hd,i)||r,r=Ad(new bd,i)||r,r=Ad(new wd,i)||r,r=Ad(new $d,i)||r,r=Ad(new pd,i)||r,r=Ad(new Sd,i)||r),e.sources=i,r}class Td{constructor(e){Object.defineProperty(this,"signal",{enumerable:!0,get:e})}static fromName(e,t){return new Td((()=>e(t)))}}function Ed(e){gm(e)?function(e){const t=e.component.scales;for(const n of D(t)){const i=Md(e,n);if(t[n].setWithExplicit("domains",i),Rd(e,n),e.component.data.isFaceted){let t=e;for(;!hm(t)&&t.parent;)t=t.parent;if("shared"===t.component.resolve.scale[n])for(const e of i.value)bn(e)&&(e.data=Cd+e.data.replace(Cd,""))}}}(e):function(e){for(const t of e.children)Ed(t);const t=e.component.scales;for(const n of D(t)){let i,r=null;for(const t of e.children){const e=t.component.scales[n];if(e){i=void 0===i?e.getWithExplicit("domains"):Kl(i,e.getWithExplicit("domains"),"domains","scale",Bd);const t=e.get("selectionExtent");r&&t&&r.param!==t.param&&yi(Yn),r=t}}t[n].setWithExplicit("domains",i),r&&t[n].set("selectionExtent",r,!0)}}(e)}function Md(e,t){const n=e.getScaleComponent(t).get("type"),{encoding:i}=e,r=function(e,t,n,i){if("unaggregated"===e){const{valid:e,reason:i}=Wd(t,n);if(!e)return void yi(i)}else if(void 0===e&&i.useUnaggregatedDomain){const{valid:e}=Wd(t,n);if(e)return"unaggregated"}return e}(e.scaleDomain(t),e.typedFieldDef(t),n,e.config.scale);return r!==e.scaleDomain(t)&&(e.specifiedScales[t]={...e.specifiedScales[t],domain:r}),"x"===t&&fa(i.x2)?fa(i.x)?Kl(qd(n,r,e,"x"),qd(n,r,e,"x2"),"domain","scale",Bd):qd(n,r,e,"x2"):"y"===t&&fa(i.y2)?fa(i.y)?Kl(qd(n,r,e,"y"),qd(n,r,e,"y2"),"domain","scale",Bd):qd(n,r,e,"y2"):qd(n,r,e,t)}function Ld(e,t,n){const i=Ei(n)?.unit;return"temporal"===t||i?function(e,t,n){return e.map((e=>({signal:`{data: ${va(e,{timeUnit:n,type:t})}}`})))}(e,t,i):[e]}function qd(e,n,i,r){const{encoding:o,markDef:a,mark:s,config:l,stack:c}=i,u=fa(o[r]),{type:f}=u,d=u.timeUnit;if(function(e){return e?.unionWith}(n)){const t=qd(e,void 0,i,r);return Yl([...Ld(n.unionWith,f,d),...t.value])}if(yn(n))return Yl([n]);if(n&&"unaggregated"!==n&&!xr(n))return Yl(Ld(n,f,d));if(c&&r===c.fieldChannel){if("normalize"===c.offset)return Xl([[0,1]]);const e=i.requestDataName(sc.Main);return Xl([{data:e,field:i.vgField(r,{suffix:"start"})},{data:e,field:i.vgField(r,{suffix:"end"})}])}const m=Ht(r)&&Ro(u)?function(e,t,n){if(!hr(n))return;const i=e.fieldDef(t),r=i.sort;if(Oo(r))return{op:"min",field:tf(i,t),order:"ascending"};const{stack:o}=e,a=o?new Set([...o.groupbyFields,...o.stackBy.map((e=>e.fieldDef.field))]):void 0;if(zo(r)){return Ud(r,o&&!a.has(r.field))}if(Fo(r)){const{encoding:t,order:n}=r,i=e.fieldDef(t),{aggregate:s,field:l}=i,c=o&&!a.has(l);if(Zt(s)||en(s))return Ud({field:ta(i),order:n},c);if(tn(s)||!s)return Ud({op:s,field:l,order:n},c)}else{if("descending"===r)return{op:"min",field:e.vgField(t),order:"descending"};if(p(["ascending",void 0],r))return!0}return}(i,r,e):void 0;if(Bo(u)){return Xl(Ld([u.datum],f,d))}const g=u;if("unaggregated"===n){const e=i.requestDataName(sc.Main),{field:t}=u;return Xl([{data:e,field:ta({field:t,aggregate:"min"})},{data:e,field:ta({field:t,aggregate:"max"})}])}if(ln(g.bin)){if(hr(e))return Xl("bin-ordinal"===e?[]:[{data:O(m)?i.requestDataName(sc.Main):i.requestDataName(sc.Raw),field:i.vgField(r,xa(g,r)?{binSuffix:"range"}:{}),sort:!0!==m&&t.isObject(m)?m:{field:i.vgField(r,{}),op:"min"}}]);{const{bin:e}=g;if(ln(e)){const t=Qf(i,g.field,e);return Xl([new Td((()=>{const e=i.getSignalName(t);return`[${e}.start, ${e}.stop]`}))])}return Xl([{data:i.requestDataName(sc.Main),field:i.vgField(r,{})}])}}if(g.timeUnit&&p(["time","utc"],e)){const e=o[it(r)];if(Eo(g,e,a,l)){const t=i.requestDataName(sc.Main),n=jo({fieldDef:g,fieldDef2:e,markDef:a,config:l}),o=mo(s)&&.5!==n&&zt(r);return Xl([{data:t,field:i.vgField(r,o?{suffix:bc}:{})},{data:t,field:i.vgField(r,{suffix:o?xc:"end"})}])}}return Xl(m?[{data:O(m)?i.requestDataName(sc.Main):i.requestDataName(sc.Raw),field:i.vgField(r),sort:m}]:[{data:i.requestDataName(sc.Main),field:i.vgField(r)}])}function Ud(e,t){const{op:n,field:i,order:r}=e;return{op:n??(t?"sum":ko),...i?{field:E(i)}:{},...r?{order:r}:{}}}function Rd(e,t){const n=e.component.scales[t],i=e.specifiedScales[t].domain,r=e.fieldDef(t)?.bin,o=xr(i)&&i,a=un(r)&&fn(r.extent)&&r.extent;(o||a)&&n.set("selectionExtent",o??a,!0)}function Wd(e,n){const{aggregate:i,type:r}=e;return i?t.isString(i)&&!an.has(i)?{valid:!1,reason:si(i)}:"quantitative"===r&&"log"===n?{valid:!1,reason:li(e)}:{valid:!0}:{valid:!1,reason:ai(e)}}function Bd(e,t,n,i){return e.explicit&&t.explicit&&yi(function(e,t,n,i){return`Conflicting ${t.toString()} property "${e.toString()}" (${X(n)} and ${X(i)}). Using the union of the two domains.`}(n,i,e.value,t.value)),{explicit:e.explicit,value:[...e.value,...t.value]}}function Id(e){const n=b(e.map((e=>{if(bn(e)){const{sort:t,...n}=e;return n}return e})),d),i=b(e.map((e=>{if(bn(e)){const t=e.sort;return void 0===t||O(t)||("op"in t&&"count"===t.op&&delete t.field,"ascending"===t.order&&delete t.order),t}})).filter((e=>void 0!==e)),d);if(0===n.length)return;if(1===n.length){const n=e[0];if(bn(n)&&i.length>0){let e=i[0];if(i.length>1){yi(fi);const n=i.filter((e=>t.isObject(e)&&"op"in e&&"min"!==e.op));e=!i.every((e=>t.isObject(e)&&"op"in e))||1!==n.length||n[0]}else if(t.isObject(e)&&"field"in e){const t=e.field;n.field===t&&(e=!e.order||{order:e.order})}return{...n,sort:e}}return n}const r=b(i.map((e=>O(e)||!("op"in e)||t.isString(e.op)&&e.op in Kt?e:(yi(function(e){return`Dropping sort property ${X(e)} as unioned domains only support boolean or op "count", "min", and "max".`}(e)),!0))),d);let o;1===r.length?o=r[0]:r.length>1&&(yi(fi),o=!0);const a=b(e.map((e=>bn(e)?e.data:null)),(e=>e));if(1===a.length&&null!==a[0]){return{data:a[0],fields:n.map((e=>e.field)),...o?{sort:o}:{}}}return{fields:n,...o?{sort:o}:{}}}function Hd(e){if(bn(e)&&t.isString(e.field))return e.field;if(function(e){return!t.isArray(e)&&"fields"in e&&!("data"in e)}(e)){let n;for(const i of e.fields)if(bn(i)&&t.isString(i.field))if(n){if(n!==i.field)return yi("Detected faceted independent scales that union domain of multiple fields from different data sources. We will use the first field. The result view size may be incorrect."),n}else n=i.field;return yi("Detected faceted independent scales that union domain of the same fields from different source. We will assume that this is the same field from a different fork of the same data source. However, if this is not the case, the result view size may be incorrect."),n}if(function(e){return!t.isArray(e)&&"fields"in e&&"data"in e}(e)){yi("Detected faceted independent scales that union domain of multiple fields from the same data source. We will use the first field. The result view size may be incorrect.");const n=e.fields[0];return t.isString(n)?n:void 0}}function Vd(e,t){const n=e.component.scales[t].get("domains").map((t=>(bn(t)&&(t.data=e.lookupDataSource(t.data)),t)));return Id(n)}function Gd(e){return vm(e)||ym(e)?e.children.reduce(((e,t)=>e.concat(Gd(t))),Yd(e)):Yd(e)}function Yd(e){return D(e.component.scales).reduce(((n,i)=>{const r=e.component.scales[i];if(r.merged)return n;const o=r.combine(),{name:a,type:s,selectionExtent:l,domains:c,range:u,reverse:f,...d}=o,m=function(e,n,i,r){if(zt(i)){if(vn(e))return{step:{signal:`${n}_step`}}}else if(t.isObject(e)&&bn(e))return{...e,data:r.lookupDataSource(e.data)};return e}(o.range,a,i,e),p=Vd(e,i),g=l?function(e,n,i,r){const o=Ru(e,n.param,n);return{signal:yr(i.get("type"))&&t.isArray(r)&&r[0]>r[1]?`isValid(${o}) && reverse(${o})`:o}}(e,l,r,p):null;return n.push({name:a,type:s,...p?{domain:p}:{},...g?{domainRaw:g}:{},range:m,...void 0!==f?{reverse:f}:{},...d}),n}),[])}class Xd extends Gl{merged=!1;constructor(e,t){super({},{name:e}),this.setWithExplicit("type",t)}domainDefinitelyIncludesZero(){return!1!==this.get("zero")||g(this.get("domains"),(e=>t.isArray(e)&&2===e.length&&t.isNumber(e[0])&&e[0]<=0&&t.isNumber(e[1])&&e[1]>=0))}}const Qd=["range","scheme"];function Jd(e,n){const i=e.fieldDef(n);if(i?.bin){const{bin:r,field:o}=i,a=rt(n),s=e.getName(a);if(t.isObject(r)&&r.binned&&void 0!==r.step)return new Td((()=>{const t=e.scaleName(n),i=`(domain("${t}")[1] - domain("${t}")[0]) / ${r.step}`;return`${e.getSignalName(s)} / (${i})`}));if(ln(r)){const t=Qf(e,o,r);return new Td((()=>{const n=e.getSignalName(t),i=`(${n}.stop - ${n}.start) / ${n}.step`;return`${e.getSignalName(s)} / (${i})`}))}}}function Kd(e,n){const i=n.specifiedScales[e],{size:r}=n,o=n.getScaleComponent(e).get("type");for(const r of Qd)if(void 0!==i[r]){const a=_r(o,r),s=Cr(e,r);if(a)if(s)yi(s);else switch(r){case"range":{const r=i.range;if(t.isArray(r)){if(zt(e))return Yl(r.map((e=>{if("width"===e||"height"===e){const t=n.getName(e),i=n.getSignalName.bind(n);return Td.fromName(i,t)}return e})))}else if(t.isObject(r))return Yl({data:n.requestDataName(sc.Main),field:r.field,sort:{op:"min",field:n.vgField(e)}});return Yl(r)}case"scheme":return Yl(Zd(i[r]))}else yi(ci(o,r,e))}const a=e===Z||"xOffset"===e?"width":"height",s=r[a];if(Fs(s))if(zt(e))if(hr(o)){const t=tm(s,n,e);if(t)return Yl({step:t})}else yi(ui(a));else if(Pt(e)){const t=e===ie?"x":"y";if("band"===n.getScaleComponent(t).get("type")){const e=nm(s,o);if(e)return Yl(e)}}const{rangeMin:l,rangeMax:u}=i,f=function(e,n){const{size:i,config:r,mark:o,encoding:a}=n,{type:s}=fa(a[e]),l=n.getScaleComponent(e),u=l.get("type"),{domain:f,domainMid:d}=n.specifiedScales[e];switch(e){case Z:case ee:if(p(["point","band"],u)){const t=im(e,i,r.view);if(Fs(t)){return{step:tm(t,n,e)}}}return em(e,n,u);case ie:case re:return function(e,t,n){const i=e===ie?"x":"y",r=t.getScaleComponent(i);if(!r)return em(i,t,n,{center:!0});const o=r.get("type"),a=t.scaleName(i),{markDef:s,config:l}=t;if("band"===o){const e=im(i,t.size,t.config.view);if(Fs(e)){const t=nm(e,n);if(t)return t}return[0,{signal:`bandwidth('${a}')`}]}{const n=t.encoding[i];if(Ro(n)&&n.timeUnit){const e=Mi(n.timeUnit,(e=>`scale('${a}', ${e})`)),i=t.config.scale.bandWithNestedOffsetPaddingInner,r=jo({fieldDef:n,markDef:s,config:l})-.5,o=0!==r?` + ${r}`:"";if(i){return[{signal:`${yn(i)?`${i.signal}/2`+o:`${i/2+r}`} * (${e})`},{signal:`${yn(i)?`(1 - ${i.signal}/2)`+o:`${1-i/2+r}`} * (${e})`}]}return[0,{signal:e}]}return c(`Cannot use ${e} scale if ${i} scale is not discrete.`)}}(e,n,u);case ye:{const a=rm(o,n.component.scales[e].get("zero"),r),s=function(e,n,i,r){const o={x:Jd(i,"x"),y:Jd(i,"y")};switch(e){case"bar":case"tick":{if(void 0!==r.scale.maxBandSize)return r.scale.maxBandSize;const e=am(n,o,r.view);return t.isNumber(e)?e-1:new Td((()=>`${e.signal} - 1`))}case"line":case"trail":case"rule":return r.scale.maxStrokeWidth;case"text":return r.scale.maxFontSize;case"point":case"square":case"circle":{if(r.scale.maxSize)return r.scale.maxSize;const e=am(n,o,r.view);return t.isNumber(e)?Math.pow(om*e,2):new Td((()=>`pow(${om} * ${e.signal}, 2)`))}}throw new Error(ni("size",e))}(o,i,n,r);return br(u)?function(e,t,n){const i=()=>{const i=On(t),r=On(e),o=`(${i} - ${r}) / (${n} - 1)`;return`sequence(${r}, ${i} + ${o}, ${o})`};return yn(t)?new Td(i):{signal:i()}}(a,s,function(e,n,i,r){switch(e){case"quantile":return n.scale.quantileCount;case"quantize":return n.scale.quantizeCount;case"threshold":return void 0!==i&&t.isArray(i)?i.length+1:(yi(function(e){return`Domain for ${e} is required for threshold scale.`}(r)),3)}}(u,r,f,e)):[a,s]}case se:return[0,2*Math.PI];case ve:return[0,360];case oe:return[0,new Td((()=>`min(${n.getSignalName(hm(n.parent)?"child_width":"width")},${n.getSignalName(hm(n.parent)?"child_height":"height")})/2`))];case we:return[r.scale.minStrokeWidth,r.scale.maxStrokeWidth];case ke:return[[1,0],[4,2],[2,1],[1,1],[1,2,4,2]];case he:return"symbol";case me:case pe:case ge:return"ordinal"===u?"nominal"===s?"category":"ordinal":void 0!==d?"diverging":"rect"===o||"geoshape"===o?"heatmap":"ramp";case be:case xe:case $e:return[r.scale.minOpacity,r.scale.maxOpacity]}}(e,n);return(void 0!==l||void 0!==u)&&_r(o,"rangeMin")&&t.isArray(f)&&2===f.length?Yl([l??f[0],u??f[1]]):Xl(f)}function Zd(e){return function(e){return!t.isString(e)&&!!e.name}(e)?{scheme:e.name,...f(e,["name"])}:{scheme:e}}function em(e,t,n){let{center:i}=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};const r=rt(e),o=t.getName(r),a=t.getSignalName.bind(t);return e===ee&&yr(n)?i?[Td.fromName((e=>`${a(e)}/2`),o),Td.fromName((e=>`-${a(e)}/2`),o)]:[Td.fromName(a,o),0]:i?[Td.fromName((e=>`-${a(e)}/2`),o),Td.fromName((e=>`${a(e)}/2`),o)]:[0,Td.fromName(a,o)]}function tm(e,n,i){const{encoding:r}=n,o=n.getScaleComponent(i),a=at(i),s=r[a];if("offset"===Ds({step:e,offsetIsDiscrete:Go(s)&&Zi(s.type)})&&Pa(r,a)){const i=n.getScaleComponent(a);let r=`domain('${n.scaleName(a)}').length`;if("band"===i.get("type")){r=`bandspace(${r}, ${i.get("paddingInner")??i.get("padding")??0}, ${i.get("paddingOuter")??i.get("padding")??0})`}const s=o.get("paddingInner")??o.get("padding");return{signal:`${e.step} * ${r} / (1-${l=s,yn(l)?l.signal:t.stringValue(l)})`}}return e.step;var l}function nm(e,t){if("offset"===Ds({step:e,offsetIsDiscrete:hr(t)}))return{step:e.step}}function im(e,t,n){const i=e===Z?"width":"height",r=t[i];return r||Ns(n,i)}function rm(e,t,n){if(t)return yn(t)?{signal:`${t.signal} ? 0 : ${rm(e,!1,n)}`}:0;switch(e){case"bar":case"tick":return n.scale.minBandSize;case"line":case"trail":case"rule":return n.scale.minStrokeWidth;case"text":return n.scale.minFontSize;case"point":case"square":case"circle":return n.scale.minSize}throw new Error(ni("size",e))}const om=.95;function am(e,t,n){const i=Fs(e.width)?e.width.step:Cs(n,"width"),r=Fs(e.height)?e.height.step:Cs(n,"height");return t.x||t.y?new Td((()=>`min(${[t.x?t.x.signal:i,t.y?t.y.signal:r].join(", ")})`)):Math.min(i,r)}function sm(e,t){gm(e)?function(e,t){const n=e.component.scales,{config:i,encoding:r,markDef:o,specifiedScales:a}=e;for(const s of D(n)){const l=a[s],c=n[s],u=e.getScaleComponent(s),f=fa(r[s]),d=l[t],m=u.get("type"),p=u.get("padding"),g=u.get("paddingInner"),h=_r(m,t),y=Cr(s,t);if(void 0!==d&&(h?y&&yi(y):yi(ci(m,t,s))),h&&void 0===y)if(void 0!==d){const e=f.timeUnit,n=f.type;switch(t){case"domainMax":case"domainMin":vi(l[t])||"temporal"===n||e?c.set(t,{signal:va(l[t],{type:n,timeUnit:e})},!0):c.set(t,l[t],!0);break;default:c.copyKeyFromObject(t,l)}}else{const n=t in lm?lm[t]({model:e,channel:s,fieldOrDatumDef:f,scaleType:m,scalePadding:p,scalePaddingInner:g,domain:l.domain,domainMin:l.domainMin,domainMax:l.domainMax,markDef:o,config:i,hasNestedOffsetScale:Aa(r,s),hasSecondaryRangeChannel:!!r[it(s)]}):i.scale[t];void 0!==n&&c.set(t,n,!1)}}}(e,t):um(e,t)}const lm={bins:e=>{let{model:t,fieldOrDatumDef:n}=e;return Ro(n)?function(e,t){const n=t.bin;if(ln(n)){const i=Qf(e,t.field,n);return new Td((()=>e.getSignalName(i)))}if(cn(n)&&un(n)&&void 0!==n.step)return{step:n.step};return}(t,n):void 0},interpolate:e=>{let{channel:t,fieldOrDatumDef:n}=e;return function(e,t){if(p([me,pe,ge],e)&&"nominal"!==t)return"hcl";return}(t,n.type)},nice:e=>{let{scaleType:n,channel:i,domain:r,domainMin:o,domainMax:a,fieldOrDatumDef:s}=e;return function(e,n,i,r,o,a){if(ua(a)?.bin||t.isArray(i)||null!=o||null!=r||p([or.TIME,or.UTC],e))return;return!!zt(n)||void 0}(n,i,r,o,a,s)},padding:e=>{let{channel:t,scaleType:n,fieldOrDatumDef:i,markDef:r,config:o}=e;return function(e,t,n,i,r,o){if(zt(e)){if(vr(t)){if(void 0!==n.continuousPadding)return n.continuousPadding;const{type:t,orient:a}=r;if("bar"===t&&(!Ro(i)||!i.bin&&!i.timeUnit)&&("vertical"===a&&"x"===e||"horizontal"===a&&"y"===e))return o.continuousBandSize}if(t===or.POINT)return n.pointPadding}return}(t,n,o.scale,i,r,o.bar)},paddingInner:e=>{let{scalePadding:t,channel:n,markDef:i,scaleType:r,config:o,hasNestedOffsetScale:a}=e;return function(e,t,n,i,r){let o=arguments.length>5&&void 0!==arguments[5]&&arguments[5];if(void 0!==e)return;if(zt(t)){const{bandPaddingInner:e,barBandPaddingInner:t,rectBandPaddingInner:i,bandWithNestedOffsetPaddingInner:a}=r;return o?a:U(e,"bar"===n?t:i)}if(Pt(t)&&i===or.BAND)return r.offsetBandPaddingInner;return}(t,n,i.type,r,o.scale,a)},paddingOuter:e=>{let{scalePadding:t,channel:n,scaleType:i,scalePaddingInner:r,config:o,hasNestedOffsetScale:a}=e;return function(e,t,n,i,r){let o=arguments.length>5&&void 0!==arguments[5]&&arguments[5];if(void 0!==e)return;if(zt(t)){const{bandPaddingOuter:e,bandWithNestedOffsetPaddingOuter:t}=r;if(o)return t;if(n===or.BAND)return U(e,yn(i)?{signal:`${i.signal}/2`}:i/2)}else if(Pt(t)){if(n===or.POINT)return.5;if(n===or.BAND)return r.offsetBandPaddingOuter}return}(t,n,i,r,o.scale,a)},reverse:e=>{let{fieldOrDatumDef:t,scaleType:n,channel:i,config:r}=e;return function(e,t,n,i){if("x"===n&&void 0!==i.xReverse)return yr(e)&&"descending"===t?yn(i.xReverse)?{signal:`!${i.xReverse.signal}`}:!i.xReverse:i.xReverse;if(yr(e)&&"descending"===t)return!0;return}(n,Ro(t)?t.sort:void 0,i,r.scale)},zero:e=>{let{channel:n,fieldOrDatumDef:i,domain:r,markDef:o,scaleType:a,config:s,hasSecondaryRangeChannel:l}=e;return function(e,n,i,r,o,a,s){if(i&&"unaggregated"!==i&&yr(o)){if(t.isArray(i)){const e=i[0],n=i[i.length-1];if(t.isNumber(e)&&e<=0&&t.isNumber(n)&&n>=0)return!0}return!1}if("size"===e&&"quantitative"===n.type&&!br(o))return!0;if((!Ro(n)||!n.bin)&&p([...Ft,..._t],e)){const{orient:t,type:n}=r;return(!p(["bar","area","line","trail"],n)||!("horizontal"===t&&"y"===e||"vertical"===t&&"x"===e))&&(!(!p(["bar","area"],n)||s)||a?.zero)}return!1}(n,i,r,o,a,s.scale,l)}};function cm(e){gm(e)?function(e){const t=e.component.scales;for(const n of It){const i=t[n];if(!i)continue;const r=Kd(n,e);i.setWithExplicit("range",r)}}(e):um(e,"range")}function um(e,t){const n=e.component.scales;for(const n of e.children)"range"===t?cm(n):sm(n,t);for(const i of D(n)){let r;for(const n of e.children){const e=n.component.scales[i];if(e){r=Kl(r,e.getWithExplicit(t),t,"scale",Ql(((e,n)=>"range"===t&&e.step&&n.step?e.step-n.step:0)))}}n[i].setWithExplicit(t,r)}}function fm(e,t,n,i){const r=function(e,t,n,i){switch(t.type){case"nominal":case"ordinal":if(qe(e)||"discrete"===Qt(e))return"shape"===e&&"ordinal"===t.type&&yi(oi(e,"ordinal")),"ordinal";if(zt(e)||Pt(e)){if(p(["rect","bar","image","rule"],n.type))return"band";if(i)return"band"}else if("arc"===n.type&&e in Ot)return"band";return bo(n[rt(e)])||Jo(t)&&t.axis?.tickBand?"band":"point";case"temporal":return qe(e)?"time":"discrete"===Qt(e)?(yi(oi(e,"temporal")),"ordinal"):Ro(t)&&t.timeUnit&&Ei(t.timeUnit).utc?"utc":"time";case"quantitative":return qe(e)?Ro(t)&&ln(t.bin)?"bin-ordinal":"linear":"discrete"===Qt(e)?(yi(oi(e,"quantitative")),"ordinal"):"linear";case"geojson":return}throw new Error(Zn(t.type))}(t,n,i,arguments.length>4&&void 0!==arguments[4]&&arguments[4]),{type:o}=e;return Ht(t)?void 0!==o?function(e,t){let n=arguments.length>2&&void 0!==arguments[2]&&arguments[2];if(!Ht(e))return!1;switch(e){case Z:case ee:case ie:case re:case se:case oe:return!!vr(t)||"band"===t||"point"===t&&!n;case ye:case we:case be:case xe:case $e:case ve:return vr(t)||br(t)||p(["band","point","ordinal"],t);case me:case pe:case ge:return"band"!==t;case ke:case he:return"ordinal"===t||br(t)}}(t,o)?Ro(n)&&(a=o,s=n.type,!(p([tr,ir],s)?void 0===a||hr(a):s===nr?p([or.TIME,or.UTC,void 0],a):s!==er||dr(a)||br(a)||void 0===a))?(yi(function(e,t){return`FieldDef does not work with "${e}" scale. We are using "${t}" scale instead.`}(o,r)),r):o:(yi(function(e,t,n){return`Channel "${e}" does not work with "${t}" scale. We are using "${n}" scale instead.`}(t,o,r)),r):r:null;var a,s}function dm(e){gm(e)?e.component.scales=function(e){const{encoding:t,mark:n,markDef:i}=e,r={};for(const o of It){const a=fa(t[o]);if(a&&n===uo&&o===he&&a.type===rr)continue;let s=a&&a.scale;if(a&&null!==s&&!1!==s){s??={};const n=fm(s,o,a,i,Aa(t,o));r[o]=new Xd(e.scaleName(`${o}`,!0),{value:n,explicit:s.type===n})}}return r}(e):e.component.scales=function(e){const t=e.component.scales={},n={},i=e.component.resolve;for(const t of e.children){dm(t);for(const r of D(t.component.scales))if(i.scale[r]??=Sf(r,e),"shared"===i.scale[r]){const e=n[r],o=t.component.scales[r].getWithExplicit("type");e?sr(e.value,o.value)?n[r]=Kl(e,o,"type","scale",mm):(i.scale[r]="independent",delete n[r]):n[r]=o}}for(const i of D(n)){const r=e.scaleName(i,!0),o=n[i];t[i]=new Xd(r,o);for(const t of e.children){const e=t.component.scales[i];e&&(t.renameScale(e.get("name"),r),e.merged=!0)}}return t}(e)}const mm=Ql(((e,t)=>cr(e)-cr(t)));class pm{constructor(){this.nameMap={}}rename(e,t){this.nameMap[e]=t}has(e){return void 0!==this.nameMap[e]}get(e){for(;this.nameMap[e]&&e!==this.nameMap[e];)e=this.nameMap[e];return e}}function gm(e){return"unit"===e?.type}function hm(e){return"facet"===e?.type}function ym(e){return"concat"===e?.type}function vm(e){return"layer"===e?.type}class bm{constructor(e,n,i,r,o,a,c){this.type=n,this.parent=i,this.config=o,this.parent=i,this.config=o,this.view=pn(c),this.name=e.name??r,this.title=hn(e.title)?{text:e.title}:e.title?pn(e.title):void 0,this.scaleNameMap=i?i.scaleNameMap:new pm,this.projectionNameMap=i?i.projectionNameMap:new pm,this.signalNameMap=i?i.signalNameMap:new pm,this.data=e.data,this.description=e.description,this.transforms=(e.transform??[]).map((e=>gl(e)?{filter:s(e.filter,Ji)}:e)),this.layout="layer"===n||"unit"===n?{}:function(e,n,i){const r=i[n],o={},{spacing:a,columns:s}=r;void 0!==a&&(o.spacing=a),void 0!==s&&(No(e)&&!_o(e.facet)||ws(e))&&(o.columns=s),ks(e)&&(o.columns=1);for(const n of Os)if(void 0!==e[n])if("spacing"===n){const i=e[n];o[n]=t.isNumber(i)?i:{row:i.row??a,column:i.column??a}}else o[n]=e[n];return o}(e,n,o),this.component={data:{sources:i?i.component.data.sources:[],outputNodes:i?i.component.data.outputNodes:{},outputNodeRefCounts:i?i.component.data.outputNodeRefCounts:{},isFaceted:No(e)||i?.component.data.isFaceted&&void 0===e.data},layoutSize:new Gl,layoutHeaders:{row:{},column:{},facet:{}},mark:null,resolve:{scale:{},axis:{},legend:{},...a?l(a):{}},selection:null,scales:null,projection:null,axes:{},legends:{}}}get width(){return this.getSizeSignalRef("width")}get height(){return this.getSizeSignalRef("height")}parse(){this.parseScale(),this.parseLayoutSize(),this.renameTopLevelLayoutSizeSignal(),this.parseSelections(),this.parseProjection(),this.parseData(),this.parseAxesAndHeaders(),this.parseLegends(),this.parseMarkGroup()}parseScale(){!function(e){let{ignoreRange:t}=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};dm(e),Ed(e);for(const t of Or)sm(e,t);t||cm(e)}(this)}parseProjection(){Gf(this)}renameTopLevelLayoutSizeSignal(){"width"!==this.getName("width")&&this.renameSignal(this.getName("width"),"width"),"height"!==this.getName("height")&&this.renameSignal(this.getName("height"),"height")}parseLegends(){Mf(this)}assembleEncodeFromView(e){const{style:t,...n}=e,i={};for(const e of D(n)){const t=n[e];void 0!==t&&(i[e]=Fn(t))}return i}assembleGroupEncodeEntry(e){let t={};return this.view&&(t=this.assembleEncodeFromView(this.view)),e||(this.description&&(t.description=Fn(this.description)),"unit"!==this.type&&"layer"!==this.type)?S(t)?void 0:t:{width:this.getSizeSignalRef("width"),height:this.getSizeSignalRef("height"),...t}}assembleLayout(){if(!this.layout)return;const{spacing:e,...t}=this.layout,{component:n,config:i}=this,r=function(e,t){const n={};for(const i of Re){const r=e[i];if(r?.facetFieldDef){const{titleAnchor:e,titleOrient:o}=of(["titleAnchor","titleOrient"],r.facetFieldDef.header,t,i),a=nf(i,o),s=hf(e,a);void 0!==s&&(n[a]=s)}}return S(n)?void 0:n}(n.layoutHeaders,i);return{padding:e,...this.assembleDefaultLayout(),...t,...r?{titleBand:r}:{}}}assembleDefaultLayout(){return{}}assembleHeaderMarks(){const{layoutHeaders:e}=this.component;let t=[];for(const n of Re)e[n].title&&t.push(lf(this,n));for(const e of af)t=t.concat(ff(this,e));return t}assembleAxes(){return function(e,t){const{x:n=[],y:i=[]}=e;return[...n.map((e=>Iu(e,"grid",t))),...i.map((e=>Iu(e,"grid",t))),...n.map((e=>Iu(e,"main",t))),...i.map((e=>Iu(e,"main",t)))].filter((e=>e))}(this.component.axes,this.config)}assembleLegends(){return Wf(this)}assembleProjections(){return Bf(this)}assembleTitle(){const{encoding:e,...t}=this.title??{},n={...gn(this.config.title).nonMarkTitleProperties,...t,...e?{encode:{update:e}}:{}};if(n.text)return p(["unit","layer"],this.type)?p(["middle",void 0],n.anchor)&&(n.frame??="group"):n.anchor??="start",S(n)?void 0:n}assembleGroup(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];const t={};e=e.concat(this.assembleSignals()),e.length>0&&(t.signals=e);const n=this.assembleLayout();n&&(t.layout=n),t.marks=[].concat(this.assembleHeaderMarks(),this.assembleMarks());const i=!this.parent||hm(this.parent)?Gd(this):[];i.length>0&&(t.scales=i);const r=this.assembleAxes();r.length>0&&(t.axes=r);const o=this.assembleLegends();return o.length>0&&(t.legends=o),t}getName(e){return _((this.name?`${this.name}_`:"")+e)}getDataName(e){return this.getName(sc[e].toLowerCase())}requestDataName(e){const t=this.getDataName(e),n=this.component.data.outputNodeRefCounts;return n[t]=(n[t]||0)+1,t}getSizeSignalRef(e){if(hm(this.parent)){const t=Ct(wf(e)),n=this.component.scales[t];if(n&&!n.merged){const e=n.get("type"),i=n.get("range");if(hr(e)&&vn(i)){const e=n.get("name"),i=Hd(Vd(this,t));if(i){return{signal:$f(e,n,ta({aggregate:"distinct",field:i},{expr:"datum"}))}}return yi(In(t)),null}}}return{signal:this.signalNameMap.get(this.getName(e))}}lookupDataSource(e){const t=this.component.data.outputNodes[e];return t?t.getSource():e}getSignalName(e){return this.signalNameMap.get(e)}renameSignal(e,t){this.signalNameMap.rename(e,t)}renameScale(e,t){this.scaleNameMap.rename(e,t)}renameProjection(e,t){this.projectionNameMap.rename(e,t)}scaleName(e,t){return t?this.getName(e):Ke(e)&&Ht(e)&&this.component.scales[e]||this.scaleNameMap.has(this.getName(e))?this.scaleNameMap.get(this.getName(e)):void 0}projectionName(e){return e?this.getName("projection"):this.component.projection&&!this.component.projection.merged||this.projectionNameMap.has(this.getName("projection"))?this.projectionNameMap.get(this.getName("projection")):void 0}correctDataNames=e=>(e.from?.data&&(e.from.data=this.lookupDataSource(e.from.data)),e.from?.facet?.data&&(e.from.facet.data=this.lookupDataSource(e.from.facet.data)),e);getScaleComponent(e){if(!this.component.scales)throw new Error("getScaleComponent cannot be called before parseScale(). Make sure you have called parseScale or use parseUnitModelWithScale().");const t=this.component.scales[e];return t&&!t.merged?t:this.parent?this.parent.getScaleComponent(e):void 0}getSelectionComponent(e,t){let n=this.component.selection[e];if(!n&&this.parent&&(n=this.parent.getSelectionComponent(e,t)),!n)throw new Error(function(e){return`Cannot find a selection named "${e}".`}(t));return n}hasAxisOrientSignalRef(){return this.component.axes.x?.some((e=>e.hasOrientSignalRef()))||this.component.axes.y?.some((e=>e.hasOrientSignalRef()))}}class xm extends bm{vgField(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const n=this.fieldDef(e);if(n)return ta(n,t)}reduceFieldDef(e,n){return function(e,n,i,r){return e?D(e).reduce(((i,o)=>{const a=e[o];return t.isArray(a)?a.reduce(((e,t)=>n.call(r,e,t,o)),i):n.call(r,i,a,o)}),i):i}(this.getMapping(),((t,n,i)=>{const r=ua(n);return r?e(t,r,i):t}),n)}forEachFieldDef(e,t){La(this.getMapping(),((t,n)=>{const i=ua(t);i&&e(i,n)}),t)}}class $m extends pc{clone(){return new $m(null,l(this.transform))}constructor(e,t){super(e),this.transform=t,this.transform=l(t);const n=this.transform.as??[void 0,void 0];this.transform.as=[n[0]??"value",n[1]??"density"];const i=this.transform.resolve??"shared";this.transform.resolve=i}dependentFields(){return new Set([this.transform.density,...this.transform.groupby??[]])}producedFields(){return new Set(this.transform.as)}hash(){return`DensityTransform ${d(this.transform)}`}assemble(){const{density:e,...t}=this.transform,n={type:"kde",field:e,...t};return n.resolve=this.transform.resolve,n}}class wm extends pc{clone(){return new wm(null,l(this.transform))}constructor(e,t){super(e),this.transform=t,this.transform=l(t)}dependentFields(){return new Set([this.transform.extent])}producedFields(){return new Set([])}hash(){return`ExtentTransform ${d(this.transform)}`}assemble(){const{extent:e,param:t}=this.transform;return{type:"extent",field:e,signal:t}}}class km extends pc{clone(){return new km(null,{...this.filter})}constructor(e,t){super(e),this.filter=t}static make(e,t){const{config:n,mark:i,markDef:r}=t;if("filter"!==Nn("invalid",r,n))return null;const o=t.reduceFieldDef(((e,n,r)=>{const o=Ht(r)&&t.getScaleComponent(r);if(o){yr(o.get("type"))&&"count"!==n.aggregate&&!fo(i)&&(e[n.field]=n)}return e}),{});return D(o).length?new km(e,o):null}dependentFields(){return new Set(D(this.filter))}producedFields(){return new Set}hash(){return`FilterInvalid ${d(this.filter)}`}assemble(){const e=D(this.filter).reduce(((e,t)=>{const n=this.filter[t],i=ta(n,{expr:"datum"});return null!==n&&("temporal"===n.type?e.push(`(isDate(${i}) || (isValid(${i}) && isFinite(+${i})))`):"quantitative"===n.type&&(e.push(`isValid(${i})`),e.push(`isFinite(+${i})`))),e}),[]);return e.length>0?{type:"filter",expr:e.join(" && ")}:null}}class Sm extends pc{clone(){return new Sm(this.parent,l(this.transform))}constructor(e,t){super(e),this.transform=t,this.transform=l(t);const{flatten:n,as:i=[]}=this.transform;this.transform.as=n.map(((e,t)=>i[t]??e))}dependentFields(){return new Set(this.transform.flatten)}producedFields(){return new Set(this.transform.as)}hash(){return`FlattenTransform ${d(this.transform)}`}assemble(){const{flatten:e,as:t}=this.transform;return{type:"flatten",fields:e,as:t}}}class Dm extends pc{clone(){return new Dm(null,l(this.transform))}constructor(e,t){super(e),this.transform=t,this.transform=l(t);const n=this.transform.as??[void 0,void 0];this.transform.as=[n[0]??"key",n[1]??"value"]}dependentFields(){return new Set(this.transform.fold)}producedFields(){return new Set(this.transform.as)}hash(){return`FoldTransform ${d(this.transform)}`}assemble(){const{fold:e,as:t}=this.transform;return{type:"fold",fields:e,as:t}}}class Fm extends pc{clone(){return new Fm(null,l(this.fields),this.geojson,this.signal)}static parseAll(e,t){if(t.component.projection&&!t.component.projection.isFit)return e;let n=0;for(const i of[[ue,ce],[de,fe]]){const r=i.map((e=>{const n=fa(t.encoding[e]);return Ro(n)?n.field:Bo(n)?{expr:`${n.datum}`}:Xo(n)?{expr:`${n.value}`}:void 0}));(r[0]||r[1])&&(e=new Fm(e,r,null,t.getName("geojson_"+n++)))}if(t.channelHasField(he)){const i=t.typedFieldDef(he);i.type===rr&&(e=new Fm(e,null,i.field,t.getName("geojson_"+n++)))}return e}constructor(e,t,n,i){super(e),this.fields=t,this.geojson=n,this.signal=i}dependentFields(){const e=(this.fields??[]).filter(t.isString);return new Set([...this.geojson?[this.geojson]:[],...e])}producedFields(){return new Set}hash(){return`GeoJSON ${this.geojson} ${this.signal} ${d(this.fields)}`}assemble(){return[...this.geojson?[{type:"filter",expr:`isValid(datum["${this.geojson}"])`}]:[],{type:"geojson",...this.fields?{fields:this.fields}:{},...this.geojson?{geojson:this.geojson}:{},signal:this.signal}]}}class zm extends pc{clone(){return new zm(null,this.projection,l(this.fields),l(this.as))}constructor(e,t,n,i){super(e),this.projection=t,this.fields=n,this.as=i}static parseAll(e,t){if(!t.projectionName())return e;for(const n of[[ue,ce],[de,fe]]){const i=n.map((e=>{const n=fa(t.encoding[e]);return Ro(n)?n.field:Bo(n)?{expr:`${n.datum}`}:Xo(n)?{expr:`${n.value}`}:void 0})),r=n[0]===de?"2":"";(i[0]||i[1])&&(e=new zm(e,t.projectionName(),i,[t.getName(`x${r}`),t.getName(`y${r}`)]))}return e}dependentFields(){return new Set(this.fields.filter(t.isString))}producedFields(){return new Set(this.as)}hash(){return`Geopoint ${this.projection} ${d(this.fields)} ${d(this.as)}`}assemble(){return{type:"geopoint",projection:this.projection,fields:this.fields,as:this.as}}}class Om extends pc{clone(){return new Om(null,l(this.transform))}constructor(e,t){super(e),this.transform=t}dependentFields(){return new Set([this.transform.impute,this.transform.key,...this.transform.groupby??[]])}producedFields(){return new Set([this.transform.impute])}processSequence(e){const{start:t=0,stop:n,step:i}=e;return{signal:`sequence(${[t,n,...i?[i]:[]].join(",")})`}}static makeFromTransform(e,t){return new Om(e,t)}static makeFromEncoding(e,t){const n=t.encoding,i=n.x,r=n.y;if(Ro(i)&&Ro(r)){const o=i.impute?i:r.impute?r:void 0;if(void 0===o)return;const a=i.impute?r:r.impute?i:void 0,{method:s,value:l,frame:c,keyvals:u}=o.impute,f=qa(t.mark,n);return new Om(e,{impute:o.field,key:a.field,...s?{method:s}:{},...void 0!==l?{value:l}:{},...c?{frame:c}:{},...void 0!==u?{keyvals:u}:{},...f.length?{groupby:f}:{}})}return null}hash(){return`Impute ${d(this.transform)}`}assemble(){const{impute:e,key:t,keyvals:n,method:i,groupby:r,value:o,frame:a=[null,null]}=this.transform,s={type:"impute",field:e,key:t,...n?{keyvals:(l=n,void 0!==l?.stop?this.processSequence(n):n)}:{},method:"value",...r?{groupby:r}:{},value:i&&"value"!==i?null:o};var l;if(i&&"value"!==i){return[s,{type:"window",as:[`imputed_${e}_value`],ops:[i],fields:[e],frame:a,ignorePeers:!1,...r?{groupby:r}:{}},{type:"formula",expr:`datum.${e} === null ? datum.imputed_${e}_value : datum.${e}`,as:e}]}return[s]}}class _m extends pc{clone(){return new _m(null,l(this.transform))}constructor(e,t){super(e),this.transform=t,this.transform=l(t);const n=this.transform.as??[void 0,void 0];this.transform.as=[n[0]??t.on,n[1]??t.loess]}dependentFields(){return new Set([this.transform.loess,this.transform.on,...this.transform.groupby??[]])}producedFields(){return new Set(this.transform.as)}hash(){return`LoessTransform ${d(this.transform)}`}assemble(){const{loess:e,on:t,...n}=this.transform;return{type:"loess",x:t,y:e,...n}}}class Cm extends pc{clone(){return new Cm(null,l(this.transform),this.secondary)}constructor(e,t,n){super(e),this.transform=t,this.secondary=n}static make(e,t,n,i){const r=t.component.data.sources,{from:o}=n;let a=null;if(function(e){return"data"in e}(o)){let e=Hm(o.data,r);e||(e=new cd(o.data),r.push(e));const n=t.getName(`lookup_${i}`);a=new gc(e,n,sc.Lookup,t.component.data.outputNodeRefCounts),t.component.data.outputNodes[n]=a}else if(function(e){return"param"in e}(o)){const e=o.param;let i;n={as:e,...n};try{i=t.getSelectionComponent(_(e),e)}catch(t){throw new Error(function(e){return`Lookups can only be performed on selection parameters. "${e}" is a variable parameter.`}(e))}if(a=i.materialized,!a)throw new Error(function(e){return`Cannot define and lookup the "${e}" selection in the same view. Try moving the lookup into a second, layered view?`}(e))}return new Cm(e,n,a.getSource())}dependentFields(){return new Set([this.transform.lookup])}producedFields(){return new Set(this.transform.as?t.array(this.transform.as):this.transform.from.fields)}hash(){return`Lookup ${d({transform:this.transform,secondary:this.secondary})}`}assemble(){let e;if(this.transform.from.fields)e={values:this.transform.from.fields,...this.transform.as?{as:t.array(this.transform.as)}:{}};else{let n=this.transform.as;t.isString(n)||(yi('If "from.fields" is not specified, "as" has to be a string that specifies the key to be used for the data from the secondary source.'),n="_lookup"),e={as:[n]}}return{type:"lookup",from:this.secondary,key:this.transform.from.key,fields:[this.transform.lookup],...e,...this.transform.default?{default:this.transform.default}:{}}}}class Nm extends pc{clone(){return new Nm(null,l(this.transform))}constructor(e,t){super(e),this.transform=t,this.transform=l(t);const n=this.transform.as??[void 0,void 0];this.transform.as=[n[0]??"prob",n[1]??"value"]}dependentFields(){return new Set([this.transform.quantile,...this.transform.groupby??[]])}producedFields(){return new Set(this.transform.as)}hash(){return`QuantileTransform ${d(this.transform)}`}assemble(){const{quantile:e,...t}=this.transform;return{type:"quantile",field:e,...t}}}class Pm extends pc{clone(){return new Pm(null,l(this.transform))}constructor(e,t){super(e),this.transform=t,this.transform=l(t);const n=this.transform.as??[void 0,void 0];this.transform.as=[n[0]??t.on,n[1]??t.regression]}dependentFields(){return new Set([this.transform.regression,this.transform.on,...this.transform.groupby??[]])}producedFields(){return new Set(this.transform.as)}hash(){return`RegressionTransform ${d(this.transform)}`}assemble(){const{regression:e,on:t,...n}=this.transform;return{type:"regression",x:t,y:e,...n}}}class Am extends pc{clone(){return new Am(null,l(this.transform))}constructor(e,t){super(e),this.transform=t}addDimensions(e){this.transform.groupby=b((this.transform.groupby??[]).concat(e),(e=>e))}producedFields(){}dependentFields(){return new Set([this.transform.pivot,this.transform.value,...this.transform.groupby??[]])}hash(){return`PivotTransform ${d(this.transform)}`}assemble(){const{pivot:e,value:t,groupby:n,limit:i,op:r}=this.transform;return{type:"pivot",field:e,value:t,...void 0!==i?{limit:i}:{},...void 0!==r?{op:r}:{},...void 0!==n?{groupby:n}:{}}}}class jm extends pc{clone(){return new jm(null,l(this.transform))}constructor(e,t){super(e),this.transform=t}dependentFields(){return new Set}producedFields(){return new Set}hash(){return`SampleTransform ${d(this.transform)}`}assemble(){return{type:"sample",size:this.transform.sample}}}function Tm(e){let t=0;return function n(i,r){if(i instanceof cd&&!i.isGenerator&&!ec(i.data)){e.push(r);r={name:null,source:r.name,transform:[]}}if(i instanceof od&&(i.parent instanceof cd&&!r.source?(r.format={...r.format,parse:i.assembleFormatParse()},r.transform.push(...i.assembleTransforms(!0))):r.transform.push(...i.assembleTransforms())),i instanceof td)return r.name||(r.name="data_"+t++),!r.source||r.transform.length>0?(e.push(r),i.data=r.name):i.data=r.source,void e.push(...i.assemble());if((i instanceof sd||i instanceof ld||i instanceof km||i instanceof qu||i instanceof ef||i instanceof zm||i instanceof ed||i instanceof Cm||i instanceof zd||i instanceof Dd||i instanceof Dm||i instanceof Sm||i instanceof $m||i instanceof _m||i instanceof Nm||i instanceof Pm||i instanceof ad||i instanceof jm||i instanceof Am||i instanceof wm)&&r.transform.push(i.assemble()),(i instanceof Kf||i instanceof vc||i instanceof Om||i instanceof Fd||i instanceof Fm)&&r.transform.push(...i.assemble()),i instanceof gc)if(r.source&&0===r.transform.length)i.setSource(r.source);else if(i.parent instanceof gc)i.setSource(r.name);else if(r.name||(r.name="data_"+t++),i.setSource(r.name),1===i.numChildren()){e.push(r);r={name:null,source:r.name,transform:[]}}switch(i.numChildren()){case 0:i instanceof gc&&(!r.source||r.transform.length>0)&&e.push(r);break;case 1:n(i.children[0],r);break;default:{r.name||(r.name="data_"+t++);let o=r.name;!r.source||r.transform.length>0?e.push(r):o=r.source;for(const e of i.children){n(e,{name:null,source:o,transform:[]})}break}}}}function Em(e){return"top"===e||"left"===e||yn(e)?"header":"footer"}function Mm(e,n){const{facet:i,config:r,child:o,component:a}=e;if(e.channelHasField(n)){const s=i[n],l=rf("title",null,r,n);let c=aa(s,r,{allowDisabling:!0,includeDefault:void 0===l||!!l});o.component.layoutHeaders[n].title&&(c=t.isArray(c)?c.join(", "):c,c+=` / ${o.component.layoutHeaders[n].title}`,o.component.layoutHeaders[n].title=null);const u=rf("labelOrient",s.header,r,n),f=null!==s.header&&U(s.header?.labels,r.header.labels,!0),d=p(["bottom","right"],u)?"footer":"header";a.layoutHeaders[n]={title:null!==s.header?c:null,facetFieldDef:s,[d]:"facet"===n?[]:[Lm(e,n,f)]}}}function Lm(e,t,n){const i="row"===t?"height":"width";return{labels:n,sizeSignal:e.child.component.layoutSize.get(i)?e.child.getSizeSignalRef(i):void 0,axes:[]}}function qm(e,t){const{child:n}=e;if(n.component.axes[t]){const{layoutHeaders:i,resolve:r}=e.component;if(r.axis[t]=Df(r,t),"shared"===r.axis[t]){const r="x"===t?"column":"row",o=i[r];for(const i of n.component.axes[t]){const t=Em(i.get("orient"));o[t]??=[Lm(e,r,!1)];const n=Iu(i,"main",e.config,{header:!0});n&&o[t][0].axes.push(n),i.mainExtracted=!0}}}}function Um(e){for(const t of e.children)t.parseLayoutSize()}function Rm(e,t){const n=wf(t),i=Ct(n),r=e.component.resolve,o=e.component.layoutSize;let a;for(const t of e.children){const o=t.component.layoutSize.getWithExplicit(n),s=r.scale[i]??Sf(i,e);if("independent"===s&&"step"===o.value){a=void 0;break}if(a){if("independent"===s&&a.value!==o.value){a=void 0;break}a=Kl(a,o,n,"")}else a=o}if(a){for(const i of e.children)e.renameSignal(i.getName(n),e.getName(t)),i.component.layoutSize.set(n,"merged",!1);o.setWithExplicit(t,a)}else o.setWithExplicit(t,{explicit:!1,value:void 0})}function Wm(e,t){const n="width"===t?"x":"y",i=e.config,r=e.getScaleComponent(n);if(r){const e=r.get("type"),n=r.get("range");if(hr(e)){const e=Ns(i.view,t);return vn(n)||Fs(e)?"step":e}return _s(i.view,t)}if(e.hasProjection||"arc"===e.mark)return _s(i.view,t);{const e=Ns(i.view,t);return Fs(e)?e.step:e}}function Bm(e,t,n){return ta(t,{suffix:`by_${ta(e)}`,...n})}class Im extends xm{constructor(e,t,n,i){super(e,"facet",t,n,i,e.resolve),this.child=vp(e.spec,this,this.getName("child"),void 0,i),this.children=[this.child],this.facet=this.initFacet(e.facet)}initFacet(e){if(!_o(e))return{facet:this.initFacetFieldDef(e,"facet")};const t=D(e),n={};for(const i of t){if(![Q,J].includes(i)){yi(ni(i,"facet"));break}const t=e[i];if(void 0===t.field){yi(ti(t,i));break}n[i]=this.initFacetFieldDef(t,i)}return n}initFacetFieldDef(e,t){const n=pa(e,t);return n.header?n.header=pn(n.header):null===n.header&&(n.header=null),n}channelHasField(e){return!!this.facet[e]}fieldDef(e){return this.facet[e]}parseData(){this.component.data=Vm(this),this.child.parseData()}parseLayoutSize(){Um(this)}parseSelections(){this.child.parseSelections(),this.component.selection=this.child.component.selection}parseMarkGroup(){this.child.parseMarkGroup()}parseAxesAndHeaders(){this.child.parseAxesAndHeaders(),function(e){for(const t of Re)Mm(e,t);qm(e,"x"),qm(e,"y")}(this)}assembleSelectionTopLevelSignals(e){return this.child.assembleSelectionTopLevelSignals(e)}assembleSignals(){return this.child.assembleSignals(),[]}assembleSelectionData(e){return this.child.assembleSelectionData(e)}getHeaderLayoutMixins(){const e={};for(const t of Re)for(const n of sf){const i=this.component.layoutHeaders[t],r=i[n],{facetFieldDef:o}=i;if(o){const n=rf("titleOrient",o.header,this.config,t);if(["right","bottom"].includes(n)){const i=nf(t,n);e.titleAnchor??={},e.titleAnchor[i]="end"}}if(r?.[0]){const r="row"===t?"height":"width",o="header"===n?"headerBand":"footerBand";"facet"===t||this.child.component.layoutSize.get(r)||(e[o]??={},e[o][t]=.5),i.title&&(e.offset??={},e.offset["row"===t?"rowTitle":"columnTitle"]=10)}}return e}assembleDefaultLayout(){const{column:e,row:t}=this.facet,n=e?this.columnDistinctSignal():t?1:void 0;let i="all";return(t||"independent"!==this.component.resolve.scale.x)&&(e||"independent"!==this.component.resolve.scale.y)||(i="none"),{...this.getHeaderLayoutMixins(),...n?{columns:n}:{},bounds:"full",align:i}}assembleLayoutSignals(){return this.child.assembleLayoutSignals()}columnDistinctSignal(){if(!(this.parent&&this.parent instanceof Im)){return{signal:`length(data('${this.getName("column_domain")}'))`}}}assembleGroupStyle(){}assembleGroup(e){return this.parent&&this.parent instanceof Im?{...this.channelHasField("column")?{encode:{update:{columns:{field:ta(this.facet.column,{prefix:"distinct"})}}}}:{},...super.assembleGroup(e)}:super.assembleGroup(e)}getCardinalityAggregateForChild(){const e=[],t=[],n=[];if(this.child instanceof Im){if(this.child.channelHasField("column")){const i=ta(this.child.facet.column);e.push(i),t.push("distinct"),n.push(`distinct_${i}`)}}else for(const i of Ft){const r=this.child.component.scales[i];if(r&&!r.merged){const o=r.get("type"),a=r.get("range");if(hr(o)&&vn(a)){const r=Hd(Vd(this.child,i));r?(e.push(r),t.push("distinct"),n.push(`distinct_${r}`)):yi(In(i))}}}return{fields:e,ops:t,as:n}}assembleFacet(){const{name:e,data:n}=this.component.data.facetRoot,{row:i,column:r}=this.facet,{fields:o,ops:a,as:s}=this.getCardinalityAggregateForChild(),l=[];for(const e of Re){const n=this.facet[e];if(n){l.push(ta(n));const{bin:c,sort:u}=n;if(ln(c)&&l.push(ta(n,{binSuffix:"end"})),zo(u)){const{field:e,op:t=ko}=u,l=Bm(n,u);i&&r?(o.push(l),a.push("max"),s.push(l)):(o.push(e),a.push(t),s.push(l))}else if(t.isArray(u)){const t=tf(n,e);o.push(t),a.push("max"),s.push(t)}}}const c=!!i&&!!r;return{name:e,data:n,groupby:l,...c||o.length>0?{aggregate:{...c?{cross:c}:{},...o.length?{fields:o,ops:a,as:s}:{}}}:{}}}facetSortFields(e){const{facet:n}=this,i=n[e];return i?zo(i.sort)?[Bm(i,i.sort,{expr:"datum"})]:t.isArray(i.sort)?[tf(i,e,{expr:"datum"})]:[ta(i,{expr:"datum"})]:[]}facetSortOrder(e){const{facet:n}=this,i=n[e];if(i){const{sort:e}=i;return[(zo(e)?e.order:!t.isArray(e)&&e)||"ascending"]}return[]}assembleLabelTitle(){const{facet:e,config:t}=this;if(e.facet)return mf(e.facet,"facet",t);const n={row:["top","bottom"],column:["left","right"]};for(const i of af)if(e[i]){const r=rf("labelOrient",e[i]?.header,t,i);if(n[i].includes(r))return mf(e[i],i,t)}}assembleMarks(){const{child:e}=this,t=function(e){const t=[],n=Tm(t);for(const t of e.children)n(t,{source:e.name,name:null,transform:[]});return t}(this.component.data.facetRoot),n=e.assembleGroupEncodeEntry(!1),i=this.assembleLabelTitle()||e.assembleTitle(),r=e.assembleGroupStyle();return[{name:this.getName("cell"),type:"group",...i?{title:i}:{},...r?{style:r}:{},from:{facet:this.assembleFacet()},sort:{field:Re.map((e=>this.facetSortFields(e))).flat(),order:Re.map((e=>this.facetSortOrder(e))).flat()},...t.length>0?{data:t}:{},...n?{encode:{update:n}}:{},...e.assembleGroup(fc(this,[]))}]}getMapping(){return this.facet}}function Hm(e,t){for(const n of t){const t=n.data;if(e.name&&n.hasName()&&e.name!==n.dataName)continue;const i=e.format?.mesh,r=t.format?.feature;if(i&&r)continue;const o=e.format?.feature;if((o||r)&&o!==r)continue;const a=t.format?.mesh;if(!i&&!a||i===a)if(tc(e)&&tc(t)){if(Y(e.values,t.values))return n}else if(ec(e)&&ec(t)){if(e.url===t.url)return n}else if(nc(e)&&e.name===n.dataName)return n}return null}function Vm(e){let t=function(e,t){if(e.data||!e.parent){if(null===e.data){const e=new cd({values:[]});return t.push(e),e}const n=Hm(e.data,t);if(n)return ic(e.data)||(n.data.format=y({},e.data.format,n.data.format)),!n.hasName()&&e.data.name&&(n.dataName=e.data.name),n;{const n=new cd(e.data);return t.push(n),n}}return e.parent.component.data.facetRoot?e.parent.component.data.facetRoot:e.parent.component.data.main}(e,e.component.data.sources);const{outputNodes:n,outputNodeRefCounts:i}=e.component.data,r=e.data,o=!(r&&(ic(r)||ec(r)||tc(r)))&&e.parent?e.parent.component.data.ancestorParse.clone():new Zl;ic(r)?(rc(r)?t=new ld(t,r.sequence):ac(r)&&(t=new sd(t,r.graticule)),o.parseNothing=!0):null===r?.format?.parse&&(o.parseNothing=!0),t=od.makeExplicit(t,e,o)??t,t=new ad(t);const a=e.parent&&vm(e.parent);(gm(e)||hm(e))&&a&&(t=Kf.makeFromEncoding(t,e)??t),e.transforms.length>0&&(t=function(e,t,n){let i=0;for(const r of t.transforms){let o,a;if(Fl(r))a=e=new ef(e,r),o="derived";else if(gl(r)){const i=id(r);a=e=od.makeWithAncestors(e,{},i,n)??e,e=new qu(e,t,r.filter)}else if(zl(r))a=e=Kf.makeFromTransform(e,r,t),o="number";else if(_l(r))o="date",void 0===n.getWithExplicit(r.field).value&&(e=new od(e,{[r.field]:o}),n.set(r.field,o,!1)),a=e=vc.makeFromTransform(e,r);else if(Cl(r))a=e=ed.makeFromTransform(e,r),o="number",ju(t)&&(e=new ad(e));else if(hl(r))a=e=Cm.make(e,t,r,i++),o="derived";else if(kl(r))a=e=new zd(e,r),o="number";else if(Sl(r))a=e=new Dd(e,r),o="number";else if(Nl(r))a=e=Fd.makeFromTransform(e,r),o="derived";else if(Pl(r))a=e=new Dm(e,r),o="derived";else if(Al(r))a=e=new wm(e,r),o="derived";else if(Dl(r))a=e=new Sm(e,r),o="derived";else if(yl(r))a=e=new Am(e,r),o="derived";else if(wl(r))e=new jm(e,r);else if(Ol(r))a=e=Om.makeFromTransform(e,r),o="derived";else if(vl(r))a=e=new $m(e,r),o="derived";else if(bl(r))a=e=new Nm(e,r),o="derived";else if(xl(r))a=e=new Pm(e,r),o="derived";else{if(!$l(r)){yi(`Ignoring an invalid transform: ${X(r)}.`);continue}a=e=new _m(e,r),o="derived"}if(a&&void 0!==o)for(const e of a.producedFields()??[])n.set(e,o,!1)}return e}(t,e,o));const s=function(e){const t={};if(gm(e)&&e.component.selection)for(const n of D(e.component.selection)){const i=e.component.selection[n];for(const e of i.project.items)!e.channel&&q(e.field)>1&&(t[e.field]="flatten")}return t}(e),l=rd(e);t=od.makeWithAncestors(t,{},{...s,...l},o)??t,gm(e)&&(t=Fm.parseAll(t,e),t=zm.parseAll(t,e)),(gm(e)||hm(e))&&(a||(t=Kf.makeFromEncoding(t,e)??t),t=vc.makeFromEncoding(t,e)??t,t=ef.parseAllForSortIndex(t,e));const c=t=Gm(sc.Raw,e,t);if(gm(e)){const n=ed.makeFromEncoding(t,e);n&&(t=n,ju(e)&&(t=new ad(t))),t=Om.makeFromEncoding(t,e)??t,t=Fd.makeFromEncoding(t,e)??t}gm(e)&&(t=km.make(t,e)??t);const u=t=Gm(sc.Main,e,t);gm(e)&&function(e,t){for(const[n,i]of z(e.component.selection??{})){const r=e.getName(`lookup_${n}`);e.component.data.outputNodes[r]=i.materialized=new gc(new qu(t,e,{param:n}),r,sc.Lookup,e.component.data.outputNodeRefCounts)}}(e,u);let f=null;if(hm(e)){const i=e.getName("facet");t=function(e,t){const{row:n,column:i}=t;if(n&&i){let t=null;for(const r of[n,i])if(zo(r.sort)){const{field:n,op:i=ko}=r.sort;e=t=new Dd(e,{joinaggregate:[{op:i,field:n,as:Bm(r,r.sort,{forAs:!0})}],groupby:[ta(r)]})}return t}return null}(t,e.facet)??t,f=new td(t,e,i,u.getSource()),n[i]=f}return{...e.component.data,outputNodes:n,outputNodeRefCounts:i,raw:c,main:u,facetRoot:f,ancestorParse:o}}function Gm(e,t,n){const{outputNodes:i,outputNodeRefCounts:r}=t.component.data,o=t.getDataName(e),a=new gc(n,o,e,r);return i[o]=a,a}class Ym extends bm{constructor(e,t,n,i){super(e,"concat",t,n,i,e.resolve),"shared"!==e.resolve?.axis?.x&&"shared"!==e.resolve?.axis?.y||yi("Axes cannot be shared in concatenated or repeated views yet (https://github.com/vega/vega-lite/issues/2415)."),this.children=this.getChildren(e).map(((e,t)=>vp(e,this,this.getName(`concat_${t}`),void 0,i)))}parseData(){this.component.data=Vm(this);for(const e of this.children)e.parseData()}parseSelections(){this.component.selection={};for(const e of this.children){e.parseSelections();for(const t of D(e.component.selection))this.component.selection[t]=e.component.selection[t]}}parseMarkGroup(){for(const e of this.children)e.parseMarkGroup()}parseAxesAndHeaders(){for(const e of this.children)e.parseAxesAndHeaders()}getChildren(e){return ks(e)?e.vconcat:Ss(e)?e.hconcat:e.concat}parseLayoutSize(){!function(e){Um(e);const t=1===e.layout.columns?"width":"childWidth",n=void 0===e.layout.columns?"height":"childHeight";Rm(e,t),Rm(e,n)}(this)}parseAxisGroup(){return null}assembleSelectionTopLevelSignals(e){return this.children.reduce(((e,t)=>t.assembleSelectionTopLevelSignals(e)),e)}assembleSignals(){return this.children.forEach((e=>e.assembleSignals())),[]}assembleLayoutSignals(){const e=vf(this);for(const t of this.children)e.push(...t.assembleLayoutSignals());return e}assembleSelectionData(e){return this.children.reduce(((e,t)=>t.assembleSelectionData(e)),e)}assembleMarks(){return this.children.map((e=>{const t=e.assembleTitle(),n=e.assembleGroupStyle(),i=e.assembleGroupEncodeEntry(!1);return{type:"group",name:e.getName("group"),...t?{title:t}:{},...n?{style:n}:{},...i?{encode:{update:i}}:{},...e.assembleGroup()}}))}assembleGroupStyle(){}assembleDefaultLayout(){const e=this.layout.columns;return{...null!=e?{columns:e}:{},bounds:"full",align:"each"}}}const Xm={disable:1,gridScale:1,scale:1,...Da,labelExpr:1,encode:1},Qm=D(Xm);class Jm extends Gl{constructor(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=arguments.length>2&&void 0!==arguments[2]&&arguments[2];super(),this.explicit=e,this.implicit=t,this.mainExtracted=n}clone(){return new Jm(l(this.explicit),l(this.implicit),this.mainExtracted)}hasAxisPart(e){return"axis"===e||("grid"===e||"title"===e?!!this.get(e):!(!1===(t=this.get(e))||null===t));var t}hasOrientSignalRef(){return yn(this.explicit.orient)}}const Km={bottom:"top",top:"bottom",left:"right",right:"left"};function Zm(e,t){if(!e)return t.map((e=>e.clone()));{if(e.length!==t.length)return;const n=e.length;for(let i=0;i{switch(n){case"title":return Ln(e,t);case"gridScale":return{explicit:e.explicit,value:U(e.value,t.value)}}return Jl(e,t,n,"axis")}));e.setWithExplicit(n,i)}return e}function tp(e,t,n,i,r){if("disable"===t)return void 0!==n;switch(n=n||{},t){case"titleAngle":case"labelAngle":return e===(yn(n.labelAngle)?n.labelAngle:H(n.labelAngle));case"values":return!!n.values;case"encode":return!!n.encoding||!!n.labelAngle;case"title":if(e===Zu(i,r))return!0}return e===n[t]}const np=new Set(["grid","translate","format","formatType","orient","labelExpr","tickCount","position","tickMinStep"]);function ip(e,t){let n=t.axis(e);const i=new Jm,r=fa(t.encoding[e]),{mark:o,config:a}=t,s=n?.orient||a["x"===e?"axisX":"axisY"]?.orient||a.axis?.orient||function(e){return"x"===e?"bottom":"left"}(e),l=t.getScaleComponent(e).get("type"),c=function(e,t,n,i){const r="band"===t?["axisDiscrete","axisBand"]:"point"===t?["axisDiscrete","axisPoint"]:dr(t)?["axisQuantitative"]:"time"===t||"utc"===t?["axisTemporal"]:[],o="x"===e?"axisX":"axisY",a=yn(n)?"axisOrient":`axis${P(n)}`,s=[...r,...r.map((e=>o+e.substr(4)))],l=["axis",a,o];return{vlOnlyAxisConfig:Vu(s,i,e,n),vgAxisConfig:Vu(l,i,e,n),axisConfigStyle:Gu([...l,...s],i)}}(e,l,s,t.config),u=void 0!==n?!n:Yu("disable",a.style,n?.style,c).configValue;if(i.set("disable",u,void 0!==n),u)return i;n=n||{};const f=function(e,t,n,i,r){const o=t?.labelAngle;if(void 0!==o)return yn(o)?o:H(o);{const{configValue:o}=Yu("labelAngle",i,t?.style,r);return void 0!==o?H(o):n!==Z||!p([ir,tr],e.type)||Ro(e)&&e.timeUnit?void 0:270}}(r,n,e,a.style,c),d=Hr(n.formatType,r,l),m=Ir(r,r.type,n.format,n.formatType,a,!0),g={fieldOrDatumDef:r,axis:n,channel:e,model:t,scaleType:l,orient:s,labelAngle:f,format:m,formatType:d,mark:o,config:a};for(const r of Qm){const o=r in Xu?Xu[r](g):za(r)?n[r]:void 0,s=void 0!==o,l=tp(o,r,n,t,e);if(s&&l)i.set(r,o,l);else{const{configValue:e,configFrom:t}=za(r)&&"values"!==r?Yu(r,a.style,n.style,c):{},u=void 0!==e;s&&!u?i.set(r,o,l):("vgAxisConfig"!==t||np.has(r)&&u||wa(e)||yn(e))&&i.set(r,e,!1)}}const h=n.encoding??{},y=ka.reduce(((n,r)=>{if(!i.hasAxisPart(r))return n;const o=kf(h[r]??{},t),a="labels"===r?function(e,t,n){const{encoding:i,config:r}=e,o=fa(i[t])??fa(i[it(t)]),a=e.axis(t)||{},{format:s,formatType:l}=a;if(Lr(l))return{text:Br({fieldOrDatumDef:o,field:"datum.value",format:s,formatType:l,config:r}),...n};if(void 0===s&&void 0===l&&r.customFormatTypes){if("quantitative"===Wo(o)){if(Jo(o)&&"normalize"===o.stack&&r.normalizedNumberFormatType)return{text:Br({fieldOrDatumDef:o,field:"datum.value",format:r.normalizedNumberFormat,formatType:r.normalizedNumberFormatType,config:r}),...n};if(r.numberFormatType)return{text:Br({fieldOrDatumDef:o,field:"datum.value",format:r.numberFormat,formatType:r.numberFormatType,config:r}),...n}}if("temporal"===Wo(o)&&r.timeFormatType&&Ro(o)&&!o.timeUnit)return{text:Br({fieldOrDatumDef:o,field:"datum.value",format:r.timeFormat,formatType:r.timeFormatType,config:r}),...n}}return n}(t,e,o):o;return void 0===a||S(a)||(n[r]={update:a}),n}),{});return S(y)||i.set("encode",y,!!n.encoding||void 0!==n.labelAngle),i}function rp(e,t){const{config:n}=e;return{...lu(e,{align:"ignore",baseline:"ignore",color:"include",size:"include",orient:"ignore",theta:"ignore"}),...Xc("x",e,{defaultPos:"mid"}),...Xc("y",e,{defaultPos:"mid"}),...Hc("size",e),...Hc("angle",e),...op(e,n,t)}}function op(e,t,n){return n?{shape:{value:n}}:Hc("shape",e)}const ap={vgMark:"rule",encodeEntry:e=>{const{markDef:t}=e,n=t.orient;return e.encoding.x||e.encoding.y||e.encoding.latitude||e.encoding.longitude?{...lu(e,{align:"ignore",baseline:"ignore",color:"include",orient:"ignore",size:"ignore",theta:"ignore"}),...eu("x",e,{defaultPos:"horizontal"===n?"zeroOrMax":"mid",defaultPos2:"zeroOrMin",range:"vertical"!==n}),...eu("y",e,{defaultPos:"vertical"===n?"zeroOrMax":"mid",defaultPos2:"zeroOrMin",range:"horizontal"!==n}),...Hc("size",e,{vgChannel:"strokeWidth"})}:{}}};function sp(e,t,n){if(void 0===Nn("align",e,n))return"center"}function lp(e,t,n){if(void 0===Nn("baseline",e,n))return"middle"}const cp={vgMark:"rect",encodeEntry:e=>{const{config:t,markDef:n}=e,i=n.orient,r="horizontal"===i?"width":"height",o="horizontal"===i?"height":"width";return{...lu(e,{align:"ignore",baseline:"ignore",color:"include",orient:"ignore",size:"ignore",theta:"ignore"}),...Xc("x",e,{defaultPos:"mid",vgChannel:"xc"}),...Xc("y",e,{defaultPos:"mid",vgChannel:"yc"}),...Hc("size",e,{defaultValue:up(e),vgChannel:r}),[o]:Fn(Nn("thickness",n,t))}}};function up(e){const{config:n,markDef:i}=e,{orient:r}=i,o="horizontal"===r?"width":"height",a=e.getScaleComponent("horizontal"===r?"x":"y"),s=Nn("size",i,n,{vgChannel:o})??n.tick.bandSize;if(void 0!==s)return s;{const e=a?a.get("range"):void 0;if(e&&vn(e)&&t.isNumber(e.step))return 3*e.step/4;return 3*Cs(n.view,o)/4}}const fp={arc:{vgMark:"arc",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"include",size:"ignore",orient:"ignore",theta:"ignore"}),...Xc("x",e,{defaultPos:"mid"}),...Xc("y",e,{defaultPos:"mid"}),...iu(e,"radius"),...iu(e,"theta")})},area:{vgMark:"area",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"include",orient:"include",size:"ignore",theta:"ignore"}),...eu("x",e,{defaultPos:"zeroOrMin",defaultPos2:"zeroOrMin",range:"horizontal"===e.markDef.orient}),...eu("y",e,{defaultPos:"zeroOrMin",defaultPos2:"zeroOrMin",range:"vertical"===e.markDef.orient}),...fu(e)})},bar:{vgMark:"rect",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"include",orient:"ignore",size:"ignore",theta:"ignore"}),...iu(e,"x"),...iu(e,"y")})},circle:{vgMark:"symbol",encodeEntry:e=>rp(e,"circle")},geoshape:{vgMark:"shape",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"include",size:"ignore",orient:"ignore",theta:"ignore"})}),postEncodingTransform:e=>{const{encoding:t}=e,n=t.shape;return[{type:"geoshape",projection:e.projectionName(),...n&&Ro(n)&&n.type===rr?{field:ta(n,{expr:"datum"})}:{}}]}},image:{vgMark:"image",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"ignore",orient:"ignore",size:"ignore",theta:"ignore"}),...iu(e,"x"),...iu(e,"y"),...Mc(e,"url")})},line:{vgMark:"line",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"include",size:"ignore",orient:"ignore",theta:"ignore"}),...Xc("x",e,{defaultPos:"mid"}),...Xc("y",e,{defaultPos:"mid"}),...Hc("size",e,{vgChannel:"strokeWidth"}),...fu(e)})},point:{vgMark:"symbol",encodeEntry:e=>rp(e)},rect:{vgMark:"rect",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"include",orient:"ignore",size:"ignore",theta:"ignore"}),...iu(e,"x"),...iu(e,"y")})},rule:ap,square:{vgMark:"symbol",encodeEntry:e=>rp(e,"square")},text:{vgMark:"text",encodeEntry:e=>{const{config:t,encoding:n}=e;return{...lu(e,{align:"include",baseline:"include",color:"include",size:"ignore",orient:"ignore",theta:"include"}),...Xc("x",e,{defaultPos:"mid"}),...Xc("y",e,{defaultPos:"mid"}),...Mc(e),...Hc("size",e,{vgChannel:"fontSize"}),...Hc("angle",e),...du("align",sp(e.markDef,n,t)),...du("baseline",lp(e.markDef,n,t)),...Xc("radius",e,{defaultPos:null}),...Xc("theta",e,{defaultPos:null})}}},tick:cp,trail:{vgMark:"trail",encodeEntry:e=>({...lu(e,{align:"ignore",baseline:"ignore",color:"include",size:"include",orient:"ignore",theta:"ignore"}),...Xc("x",e,{defaultPos:"mid"}),...Xc("y",e,{defaultPos:"mid"}),...Hc("size",e),...fu(e)})}};function dp(e){if(p([to,Kr,so],e.mark)){const t=qa(e.mark,e.encoding);if(t.length>0)return function(e,t){return[{name:e.getName("pathgroup"),type:"group",from:{facet:{name:mp+e.requestDataName(sc.Main),data:e.requestDataName(sc.Main),groupby:t}},encode:{update:{width:{field:{group:"width"}},height:{field:{group:"height"}}}},marks:gp(e,{fromPrefix:mp})}]}(e,t)}else if(e.mark===Zr){const t=wn.some((t=>Nn(t,e.markDef,e.config)));if(e.stack&&!e.fieldDef("size")&&t)return function(e){const[t]=gp(e,{fromPrefix:pp}),n=e.scaleName(e.stack.fieldChannel),i=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return e.vgField(e.stack.fieldChannel,t)},r=(e,t)=>`${e}(${[i({prefix:"min",suffix:"start",expr:t}),i({prefix:"max",suffix:"start",expr:t}),i({prefix:"min",suffix:"end",expr:t}),i({prefix:"max",suffix:"end",expr:t})].map((e=>`scale('${n}',${e})`)).join(",")})`;let o,a;"x"===e.stack.fieldChannel?(o={...u(t.encode.update,["y","yc","y2","height",...wn]),x:{signal:r("min","datum")},x2:{signal:r("max","datum")},clip:{value:!0}},a={x:{field:{group:"x"},mult:-1},height:{field:{group:"height"}}},t.encode.update={...f(t.encode.update,["y","yc","y2"]),height:{field:{group:"height"}}}):(o={...u(t.encode.update,["x","xc","x2","width"]),y:{signal:r("min","datum")},y2:{signal:r("max","datum")},clip:{value:!0}},a={y:{field:{group:"y"},mult:-1},width:{field:{group:"width"}}},t.encode.update={...f(t.encode.update,["x","xc","x2"]),width:{field:{group:"width"}}});for(const n of wn){const i=Pn(n,e.markDef,e.config);t.encode.update[n]?(o[n]=t.encode.update[n],delete t.encode.update[n]):i&&(o[n]=Fn(i)),i&&(t.encode.update[n]={value:0})}const s=[];if(e.stack.groupbyChannels?.length>0)for(const t of e.stack.groupbyChannels){const n=e.fieldDef(t),i=ta(n);i&&s.push(i),(n?.bin||n?.timeUnit)&&s.push(ta(n,{binSuffix:"end"}))}o=["stroke","strokeWidth","strokeJoin","strokeCap","strokeDash","strokeDashOffset","strokeMiterLimit","strokeOpacity"].reduce(((n,i)=>{if(t.encode.update[i])return{...n,[i]:t.encode.update[i]};{const t=Pn(i,e.markDef,e.config);return void 0!==t?{...n,[i]:Fn(t)}:n}}),o),o.stroke&&(o.strokeForeground={value:!0},o.strokeOffset={value:0});return[{type:"group",from:{facet:{data:e.requestDataName(sc.Main),name:pp+e.requestDataName(sc.Main),groupby:s,aggregate:{fields:[i({suffix:"start"}),i({suffix:"start"}),i({suffix:"end"}),i({suffix:"end"})],ops:["min","max","min","max"]}}},encode:{update:o},marks:[{type:"group",encode:{update:a},marks:[t]}]}]}(e)}return gp(e)}const mp="faceted_path_";const pp="stack_group_";function gp(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{fromPrefix:""};const{mark:i,markDef:r,encoding:o,config:a}=e,s=U(r.clip,function(e){const t=e.getScaleComponent("x"),n=e.getScaleComponent("y");return!(!t?.get("selectionExtent")&&!n?.get("selectionExtent"))||void 0}(e),function(e){const t=e.component.projection;return!(!t||t.isFit)||void 0}(e)),l=Cn(r),c=o.key,u=function(e){const{encoding:n,stack:i,mark:r,markDef:o,config:a}=e,s=n.order;if(!(!t.isArray(s)&&Xo(s)&&m(s.value)||!s&&m(Nn("order",o,a)))){if((t.isArray(s)||Ro(s))&&!i)return Tn(s,{expr:"datum"});if(fo(r)){const i="horizontal"===o.orient?"y":"x",r=n[i];if(Ro(r)){const n=r.sort;return t.isArray(n)?{field:ta(r,{prefix:i,suffix:"sort_index",expr:"datum"})}:zo(n)?{field:ta({aggregate:ja(e.encoding)?n.op:void 0,field:n.field},{expr:"datum"})}:Fo(n)?{field:ta(e.fieldDef(n.encoding),{expr:"datum"}),order:n.order}:null===n?void 0:{field:ta(r,{binSuffix:e.stack?.impute?"mid":void 0,expr:"datum"})}}}}}(e),f=function(e){if(!e.component.selection)return null;const t=D(e.component.selection).length;let n=t,i=e.parent;for(;i&&0===n;)n=D(i.component.selection).length,i=i.parent;return n?{interactive:t>0||"geoshape"===e.mark||!!e.encoding.tooltip||!!e.markDef.tooltip}:null}(e),d=Nn("aria",r,a),p=fp[i].postEncodingTransform?fp[i].postEncodingTransform(e):null;return[{name:e.getName("marks"),type:fp[i].vgMark,...s?{clip:s}:{},...l?{style:l}:{},...c?{key:c.field}:{},...u?{sort:u}:{},...f||{},...!1===d?{aria:d}:{},from:{data:n.fromPrefix+e.requestDataName(sc.Main)},encode:{update:fp[i].encodeEntry(e)},...p?{transform:p}:{}}]}class hp extends xm{specifiedScales={};specifiedAxes={};specifiedLegends={};specifiedProjection={};selection=[];children=[];constructor(e,n,i){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},o=arguments.length>4?arguments[4]:void 0;super(e,"unit",n,i,o,void 0,zs(e)?e.view:void 0);const a=go(e.mark)?{...e.mark}:{type:e.mark},s=a.type;void 0===a.filled&&(a.filled=function(e,t,n){let{graticule:i}=n;if(i)return!1;const r=Pn("filled",e,t),o=e.type;return U(r,o!==no&&o!==to&&o!==ro)}(a,o,{graticule:e.data&&ac(e.data)}));const l=this.encoding=function(e,n,i,r){const o={};for(const t of D(e))Ke(t)||yi(`${a=t}-encoding is dropped as ${a} is not a valid encoding channel.`);var a;for(let a of lt){if(!e[a])continue;const s=e[a];if(Pt(a)){const e=st(a),t=o[e];if(Ro(t)&&Ki(t.type)&&Ro(s)&&!t.timeUnit){yi(Kn(e));continue}}if("angle"!==a||"arc"!==n||e.theta||(yi("Arc marks uses theta channel rather than angle, replacing angle with theta."),a=se),Ea(e,a,n)){if(a===ye&&"line"===n){const t=ua(e[a]);if(t?.aggregate){yi("Line marks cannot encode size with a non-groupby field. You may want to use trail marks instead.");continue}}if(a===me&&(i?"fill"in e:"stroke"in e))yi(ei("encoding",{fill:"fill"in e,stroke:"stroke"in e}));else if(a===Fe||a===De&&!t.isArray(s)&&!Xo(s)||a===Oe&&t.isArray(s)){if(s){if(a===De){const t=e[a];if(Mo(t)){o[a]=t;continue}}o[a]=t.array(s).reduce(((e,t)=>(Ro(t)?e.push(pa(t,a)):yi(ti(t,a)),e)),[])}}else{if(a===Oe&&null===s)o[a]=null;else if(!(Ro(s)||Bo(s)||Xo(s)||Lo(s)||yn(s))){yi(ti(s,a));continue}o[a]=da(s,a,r)}}else yi(ni(a,n))}return o}(e.encoding||{},s,a.filled,o);this.markDef=Zs(a,l,o),this.size=function(e){let{encoding:t,size:n}=e;for(const e of Ft){const i=rt(e);Fs(n[i])&&Io(t[e])&&(delete n[i],yi(ui(i)))}return n}({encoding:l,size:zs(e)?{...r,...e.width?{width:e.width}:{},...e.height?{height:e.height}:{}}:r}),this.stack=Ks(this.markDef,l),this.specifiedScales=this.initScales(s,l),this.specifiedAxes=this.initAxes(l),this.specifiedLegends=this.initLegends(l),this.specifiedProjection=e.projection,this.selection=(e.params??[]).filter((e=>xs(e)))}get hasProjection(){const{encoding:e}=this,t=this.mark===uo,n=e&&Me.some((t=>Go(e[t])));return t||n}scaleDomain(e){const t=this.specifiedScales[e];return t?t.domain:void 0}axis(e){return this.specifiedAxes[e]}legend(e){return this.specifiedLegends[e]}initScales(e,t){return It.reduce(((e,n)=>{const i=fa(t[n]);return i&&(e[n]=this.initScale(i.scale??{})),e}),{})}initScale(e){const{domain:n,range:i}=e,r=pn(e);return t.isArray(n)&&(r.domain=n.map(Sn)),t.isArray(i)&&(r.range=i.map(Sn)),r}initAxes(e){return Ft.reduce(((t,n)=>{const i=e[n];if(Go(i)||n===Z&&Go(e.x2)||n===ee&&Go(e.y2)){const e=Go(i)?i.axis:void 0;t[n]=e?this.initAxis({...e}):e}return t}),{})}initAxis(e){const t=D(e),n={};for(const i of t){const t=e[i];n[i]=wa(t)?kn(t):Sn(t)}return n}initLegends(e){return Wt.reduce(((t,n)=>{const i=fa(e[n]);if(i&&function(e){switch(e){case me:case pe:case ge:case ye:case he:case be:case we:case ke:return!0;case xe:case $e:case ve:return!1}}(n)){const e=i.legend;t[n]=e?pn(e):e}return t}),{})}parseData(){this.component.data=Vm(this)}parseLayoutSize(){!function(e){const{size:t,component:n}=e;for(const i of Ft){const r=rt(i);if(t[r]){const e=t[r];n.layoutSize.set(r,Fs(e)?"step":e,!0)}else{const t=Wm(e,r);n.layoutSize.set(r,t,!1)}}}(this)}parseSelections(){this.component.selection=function(e,n){const i={},r=e.config.selection;if(!n||!n.length)return i;for(const o of n){const n=_(o.name),a=o.select,s=t.isString(a)?a:a.type,c=t.isObject(a)?l(a):{type:s},u=r[s];for(const e in u)"fields"!==e&&"encodings"!==e&&("mark"===e&&(c[e]={...u[e],...c[e]}),void 0!==c[e]&&!0!==c[e]||(c[e]=l(u[e]??c[e])));const f=i[n]={...c,name:n,type:s,init:o.value,bind:o.bind,events:t.isString(c.on)?t.parseSelector(c.on,"scope"):t.array(l(c.on))},d=l(o);for(const t of Pu)t.defined(f)&&t.parse&&t.parse(e,f,d)}return i}(this,this.selection)}parseMarkGroup(){this.component.mark=dp(this)}parseAxesAndHeaders(){var e;this.component.axes=(e=this,Ft.reduce(((t,n)=>(e.component.scales[n]&&(t[n]=[ip(n,e)]),t)),{}))}assembleSelectionTopLevelSignals(e){return function(e,n){let i=!1;for(const r of F(e.component.selection??{})){const o=r.name,a=t.stringValue(o+Ou);if(0===n.filter((e=>e.name===o)).length){const e="global"===r.resolve?"union":r.resolve,i="point"===r.type?", true, true)":")";n.push({name:r.name,update:`${Nu}(${a}, ${t.stringValue(e)}${i}`})}i=!0;for(const t of Pu)t.defined(r)&&t.topLevelSignals&&(n=t.topLevelSignals(e,r,n))}i&&0===n.filter((e=>"unit"===e.name)).length&&n.unshift({name:"unit",value:{},on:[{events:"pointermove",update:"isTuple(group()) ? group() : unit"}]});return mc(n)}(this,e)}assembleSignals(){return[...Hu(this),...uc(this,[])]}assembleSelectionData(e){return function(e,t){const n=[...t],i=Au(e,{escape:!1});for(const t of F(e.component.selection??{})){const e={name:t.name+Ou};if(t.project.hasSelectionId&&(e.transform=[{type:"collect",sort:{field:hs}}]),t.init){const n=t.project.items.map(lc);e.values=t.project.hasSelectionId?t.init.map((e=>({unit:i,[hs]:cc(e,!1)[0]}))):t.init.map((e=>({unit:i,fields:n,values:cc(e,!1)})))}n.filter((e=>e.name===t.name+Ou)).length||n.push(e)}return n}(this,e)}assembleLayout(){return null}assembleLayoutSignals(){return vf(this)}assembleMarks(){let e=this.component.mark??[];return this.parent&&vm(this.parent)||(e=dc(this,e)),e.map(this.correctDataNames)}assembleGroupStyle(){const{style:e}=this.view||{};return void 0!==e?e:this.encoding.x||this.encoding.y?"cell":"view"}getMapping(){return this.encoding}get mark(){return this.markDef.type}channelHasField(e){return Na(this.encoding,e)}fieldDef(e){return ua(this.encoding[e])}typedFieldDef(e){const t=this.fieldDef(e);return Yo(t)?t:null}}class yp extends bm{constructor(e,t,n,i,r){super(e,"layer",t,n,r,e.resolve,e.view);const o={...i,...e.width?{width:e.width}:{},...e.height?{height:e.height}:{}};this.children=e.layer.map(((e,t)=>{if(Hs(e))return new yp(e,this,this.getName(`layer_${t}`),o,r);if(_a(e))return new hp(e,this,this.getName(`layer_${t}`),o,r);throw new Error(qn(e))}))}parseData(){this.component.data=Vm(this);for(const e of this.children)e.parseData()}parseLayoutSize(){var e;Um(e=this),Rm(e,"width"),Rm(e,"height")}parseSelections(){this.component.selection={};for(const e of this.children){e.parseSelections();for(const t of D(e.component.selection))this.component.selection[t]=e.component.selection[t]}}parseMarkGroup(){for(const e of this.children)e.parseMarkGroup()}parseAxesAndHeaders(){!function(e){const{axes:t,resolve:n}=e.component,i={top:0,bottom:0,right:0,left:0};for(const i of e.children){i.parseAxesAndHeaders();for(const r of D(i.component.axes))n.axis[r]=Df(e.component.resolve,r),"shared"===n.axis[r]&&(t[r]=Zm(t[r],i.component.axes[r]),t[r]||(n.axis[r]="independent",delete t[r]))}for(const r of Ft){for(const o of e.children)if(o.component.axes[r]){if("independent"===n.axis[r]){t[r]=(t[r]??[]).concat(o.component.axes[r]);for(const e of o.component.axes[r]){const{value:t,explicit:n}=e.getWithExplicit("orient");if(!yn(t)){if(i[t]>0&&!n){const n=Km[t];i[t]>i[n]&&e.set("orient",n,!1)}i[t]++}}}delete o.component.axes[r]}if("independent"===n.axis[r]&&t[r]&&t[r].length>1)for(const[e,n]of(t[r]||[]).entries())e>0&&n.get("grid")&&!n.explicit.grid&&(n.implicit.grid=!1)}}(this)}assembleSelectionTopLevelSignals(e){return this.children.reduce(((e,t)=>t.assembleSelectionTopLevelSignals(e)),e)}assembleSignals(){return this.children.reduce(((e,t)=>e.concat(t.assembleSignals())),Hu(this))}assembleLayoutSignals(){return this.children.reduce(((e,t)=>e.concat(t.assembleLayoutSignals())),vf(this))}assembleSelectionData(e){return this.children.reduce(((e,t)=>t.assembleSelectionData(e)),e)}assembleGroupStyle(){const e=new Set;for(const n of this.children)for(const i of t.array(n.assembleGroupStyle()))e.add(i);const n=Array.from(e);return n.length>1?n:1===n.length?n[0]:void 0}assembleTitle(){let e=super.assembleTitle();if(e)return e;for(const t of this.children)if(e=t.assembleTitle(),e)return e}assembleLayout(){return null}assembleMarks(){return function(e,t){for(const n of e.children)gm(n)&&(t=dc(n,t));return t}(this,this.children.flatMap((e=>e.assembleMarks())))}assembleLegends(){return this.children.reduce(((e,t)=>e.concat(t.assembleLegends())),Wf(this))}}function vp(e,t,n,i,r){if(No(e))return new Im(e,t,n,r);if(Hs(e))return new yp(e,t,n,i,r);if(_a(e))return new hp(e,t,n,i,r);if(function(e){return ks(e)||Ss(e)||ws(e)}(e))return new Ym(e,t,n,r);throw new Error(qn(e))}const bp=n;e.accessPathDepth=q,e.accessPathWithDatum=A,e.compile=function(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};var i;n.logger&&(i=n.logger,hi=i),n.fieldTitle&&oa(n.fieldTitle);try{const i=qs(t.mergeConfig(n.config,e.config)),r=Ul(e,i),o=vp(r,null,"",void 0,i);o.parse(),function(e,t){Pd(e.sources);let n=0,i=0;for(let i=0;i2&&void 0!==arguments[2]?arguments[2]:{},i=arguments.length>3?arguments[3]:void 0;const r=e.config?Bs(e.config):void 0,o=[].concat(e.assembleSelectionData([]),function(e,t){const n=[],i=Tm(n);let r=0;for(const t of e.sources){t.hasName()||(t.dataName="source_"+r++);const e=t.assemble();i(t,e)}for(const e of n)0===e.transform.length&&delete e.transform;let o=0;for(const[e,t]of n.entries())0!==(t.transform??[]).length||t.source||n.splice(o++,0,n.splice(e,1)[0]);for(const t of n)for(const n of t.transform??[])"lookup"===n.type&&(n.from=e.outputNodes[n.from].getSource());for(const e of n)e.name in t&&(e.values=t[e.name]);return n}(e.component.data,n)),a=e.assembleProjections(),s=e.assembleTitle(),l=e.assembleGroupStyle(),c=e.assembleGroupEncodeEntry(!0);let u=e.assembleLayoutSignals();u=u.filter((e=>"width"!==e.name&&"height"!==e.name||void 0===e.value||(t[e.name]=+e.value,!1)));const{params:f,...d}=t;return{$schema:"https://vega.github.io/schema/vega/v5.json",...e.description?{description:e.description}:{},...d,...s?{title:s}:{},...l?{style:l}:{},...c?{encode:{update:c}}:{},data:o,...a.length>0?{projections:a}:{},...e.assembleGroup([...u,...e.assembleSelectionTopLevelSignals([]),...$s(f)]),...r?{config:r}:{},...i?{usermeta:i}:{}}}(o,function(e,n,i,r){const o=r.component.layoutSize.get("width"),a=r.component.layoutSize.get("height");void 0===n?(n={type:"pad"},r.hasAxisOrientSignalRef()&&(n.resize=!0)):t.isString(n)&&(n={type:n});if(o&&a&&(s=n.type,"fit"===s||"fit-x"===s||"fit-y"===s))if("step"===o&&"step"===a)yi(Bn()),n.type="pad";else if("step"===o||"step"===a){const e="step"===o?"width":"height";yi(Bn(Ct(e)));const t="width"===e?"height":"width";n.type=function(e){return e?`fit-${Ct(e)}`:"fit"}(t)}var s;return{...1===D(n).length&&n.type?"pad"===n.type?{}:{autosize:n.type}:{autosize:n},...Vl(i,!1),...Vl(e,!0)}}(e,r.autosize,i,o),e.datasets,e.usermeta);return{spec:a,normalized:r}}finally{n.logger&&(hi=gi),n.fieldTitle&&oa(ia)}},e.contains=p,e.deepEqual=Y,e.deleteNestedProperty=N,e.duplicate=l,e.entries=z,e.every=h,e.fieldIntersection=k,e.flatAccessWithDatum=j,e.getFirstDefined=U,e.hasIntersection=$,e.hash=d,e.internalField=B,e.isBoolean=O,e.isEmpty=S,e.isEqual=function(e,t){const n=D(e),i=D(t);if(n.length!==i.length)return!1;for(const i of n)if(e[i]!==t[i])return!1;return!0},e.isInternalField=I,e.isNullOrFalse=m,e.isNumeric=V,e.keys=D,e.logicalExpr=C,e.mergeDeep=y,e.never=c,e.normalize=Ul,e.normalizeAngle=H,e.omit=f,e.pick=u,e.prefixGenerator=w,e.removePathFromField=L,e.replaceAll=M,e.replacePathInField=E,e.resetIdCounter=function(){R=42},e.setEqual=x,e.some=g,e.stringify=X,e.titleCase=P,e.unique=b,e.uniqueId=W,e.vals=F,e.varName=_,e.version=bp})); +//# sourceMappingURL=vega-lite.min.js.map diff --git a/docs/javascripts/vega@5.js b/docs/javascripts/vega@5.js new file mode 100644 index 000000000..b0bd34892 --- /dev/null +++ b/docs/javascripts/vega@5.js @@ -0,0 +1,2 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).vega={})}(this,(function(t){"use strict";function e(t,e,n){return t.fields=e||[],t.fname=n,t}function n(t){return null==t?null:t.fname}function r(t){return null==t?null:t.fields}function i(t){return 1===t.length?o(t[0]):a(t)}const o=t=>function(e){return e[t]},a=t=>{const e=t.length;return function(n){for(let r=0;rr&&c(),u=r=i+1):"]"===o&&(u||s("Access path missing open bracket: "+t),u>0&&c(),u=0,r=i+1):i>r?c():r=i+1}return u&&s("Access path missing closing bracket: "+t),a&&s("Access path missing closing quote: "+t),i>r&&(i++,c()),e}function l(t,n,r){const o=u(t);return t=1===o.length?o[0]:t,e((r&&r.get||i)(o),[t],n||t)}const c=l("id"),f=e((t=>t),[],"identity"),h=e((()=>0),[],"zero"),d=e((()=>1),[],"one"),p=e((()=>!0),[],"true"),g=e((()=>!1),[],"false");function m(t,e,n){const r=[e].concat([].slice.call(n));console[t].apply(console,r)}const y=0,v=1,_=2,x=3,b=4;function w(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:m,r=t||y;return{level(t){return arguments.length?(r=+t,this):r},error(){return r>=v&&n(e||"error","ERROR",arguments),this},warn(){return r>=_&&n(e||"warn","WARN",arguments),this},info(){return r>=x&&n(e||"log","INFO",arguments),this},debug(){return r>=b&&n(e||"log","DEBUG",arguments),this}}}var k=Array.isArray;function A(t){return t===Object(t)}const M=t=>"__proto__"!==t;function E(){for(var t=arguments.length,e=new Array(t),n=0;n{for(const n in e)if("signals"===n)t.signals=C(t.signals,e.signals);else{const r="legend"===n?{layout:1}:"style"===n||null;D(t,n,e[n],r)}return t}),{})}function D(t,e,n,r){if(!M(e))return;let i,o;if(A(n)&&!k(n))for(i in o=A(t[e])?t[e]:t[e]={},n)r&&(!0===r||r[i])?D(o,i,n[i]):M(i)&&(o[i]=n[i]);else t[e]=n}function C(t,e){if(null==t)return e;const n={},r=[];function i(t){n[t.name]||(n[t.name]=1,r.push(t))}return e.forEach(i),t.forEach(i),r}function F(t){return t[t.length-1]}function S(t){return null==t||""===t?null:+t}const $=t=>e=>t*Math.exp(e),T=t=>e=>Math.log(t*e),B=t=>e=>Math.sign(e)*Math.log1p(Math.abs(e/t)),z=t=>e=>Math.sign(e)*Math.expm1(Math.abs(e))*t,N=t=>e=>e<0?-Math.pow(-e,t):Math.pow(e,t);function O(t,e,n,r){const i=n(t[0]),o=n(F(t)),a=(o-i)*e;return[r(i-a),r(o-a)]}function R(t,e){return O(t,e,S,f)}function U(t,e){var n=Math.sign(t[0]);return O(t,e,T(n),$(n))}function L(t,e,n){return O(t,e,N(n),N(1/n))}function q(t,e,n){return O(t,e,B(n),z(n))}function P(t,e,n,r,i){const o=r(t[0]),a=r(F(t)),s=null!=e?r(e):(o+a)/2;return[i(s+(o-s)*n),i(s+(a-s)*n)]}function j(t,e,n){return P(t,e,n,S,f)}function I(t,e,n){const r=Math.sign(t[0]);return P(t,e,n,T(r),$(r))}function W(t,e,n,r){return P(t,e,n,N(r),N(1/r))}function H(t,e,n,r){return P(t,e,n,B(r),z(r))}function Y(t){return 1+~~(new Date(t).getMonth()/3)}function G(t){return 1+~~(new Date(t).getUTCMonth()/3)}function V(t){return null!=t?k(t)?t:[t]:[]}function X(t,e,n){let r,i=t[0],o=t[1];return o=n-e?[e,n]:[i=Math.min(Math.max(i,e),n-r),i+r]}function J(t){return"function"==typeof t}const Z="descending";function Q(t,n,i){i=i||{},n=V(n)||[];const o=[],a=[],s={},u=i.comparator||tt;return V(t).forEach(((t,e)=>{null!=t&&(o.push(n[e]===Z?-1:1),a.push(t=J(t)?t:l(t,null,i)),(r(t)||[]).forEach((t=>s[t]=1)))})),0===a.length?null:e(u(a,o),Object.keys(s))}const K=(t,e)=>(te||null==e)&&null!=t?1:(e=e instanceof Date?+e:e,(t=t instanceof Date?+t:t)!==t&&e==e?-1:e!=e&&t==t?1:0),tt=(t,e)=>1===t.length?et(t[0],e[0]):nt(t,e,t.length),et=(t,e)=>function(n,r){return K(t(n),t(r))*e},nt=(t,e,n)=>(e.push(0),function(r,i){let o,a=0,s=-1;for(;0===a&&++st}function it(t,e){let n;return r=>{n&&clearTimeout(n),n=setTimeout((()=>(e(r),n=null)),t)}}function ot(t){for(let e,n,r=1,i=arguments.length;ro&&(o=r))}else{for(r=e(t[a]);ao&&(o=r))}return[i,o]}function st(t,e){const n=t.length;let r,i,o,a,s,u=-1;if(null==e){for(;++u=i){r=o=i;break}if(u===n)return[-1,-1];for(a=s=u;++ui&&(r=i,a=u),o=i){r=o=i;break}if(u===n)return[-1,-1];for(a=s=u;++ui&&(r=i,a=u),or(t)?n[t]:void 0,set(t,e){return r(t)||(++i.size,n[t]===ct&&--i.empty),n[t]=e,this},delete(t){return r(t)&&(--i.size,++i.empty,n[t]=ct),this},clear(){i.size=i.empty=0,i.object=n={}},test(t){return arguments.length?(e=t,i):e},clean(){const t={};let r=0;for(const i in n){const o=n[i];o===ct||e&&e(o)||(t[i]=o,++r)}i.size=r,i.empty=0,i.object=n=t}};return t&&Object.keys(t).forEach((e=>{i.set(e,t[e])})),i}function ht(t,e,n,r,i,o){if(!n&&0!==n)return o;const a=+n;let s,u=t[0],l=F(t);la&&(i=o,o=a,a=i),r=void 0===r||r,((n=void 0===n||n)?o<=t:ot.replace(/\\(.)/g,"$1"))):V(t));const o=t&&t.length,a=r&&r.get||i,s=t=>a(n?[t]:u(t));let l;if(o)if(1===o){const e=s(t[0]);l=function(t){return""+e(t)}}else{const e=t.map(s);l=function(t){let n=""+e[0](t),r=0;for(;++r{e={},n={},r=0},o=(i,o)=>(++r>t&&(n=e,e={},r=1),e[i]=o);return i(),{clear:i,has:t=>lt(e,t)||lt(n,t),get:t=>lt(e,t)?e[t]:lt(n,t)?o(t,n[t]):void 0,set:(t,n)=>lt(e,t)?e[t]=n:o(t,n)}}function At(t,e,n,r){const i=e.length,o=n.length;if(!o)return e;if(!i)return n;const a=r||new e.constructor(i+o);let s=0,u=0,l=0;for(;s0?n[u++]:e[s++];for(;s=0;)n+=t;return n}function Et(t,e,n,r){const i=n||" ",o=t+"",a=e-o.length;return a<=0?o:"left"===r?Mt(i,a)+o:"center"===r?Mt(i,~~(a/2))+o+Mt(i,Math.ceil(a/2)):o+Mt(i,a)}function Dt(t){return t&&F(t)-t[0]||0}function Ct(t){return k(t)?"["+t.map(Ct)+"]":A(t)||xt(t)?JSON.stringify(t).replace("\u2028","\\u2028").replace("\u2029","\\u2029"):t}function Ft(t){return null==t||""===t?null:!(!t||"false"===t||"0"===t)&&!!t}const St=t=>vt(t)||mt(t)?t:Date.parse(t);function $t(t,e){return e=e||St,null==t||""===t?null:e(t)}function Tt(t){return null==t||""===t?null:t+""}function Bt(t){const e={},n=t.length;for(let r=0;r9999?"+"+It(e,6):It(e,4))+"-"+It(t.getUTCMonth()+1,2)+"-"+It(t.getUTCDate(),2)+(o?"T"+It(n,2)+":"+It(r,2)+":"+It(i,2)+"."+It(o,3)+"Z":i?"T"+It(n,2)+":"+It(r,2)+":"+It(i,2)+"Z":r||n?"T"+It(n,2)+":"+It(r,2)+"Z":"")}function Ht(t){var e=new RegExp('["'+t+"\n\r]"),n=t.charCodeAt(0);function r(t,e){var r,i=[],o=t.length,a=0,s=0,u=o<=0,l=!1;function c(){if(u)return Rt;if(l)return l=!1,Ot;var e,r,i=a;if(t.charCodeAt(i)===Ut){for(;a++=o?u=!0:(r=t.charCodeAt(a++))===Lt?l=!0:r===qt&&(l=!0,t.charCodeAt(a)===Lt&&++a),t.slice(i+1,e-1).replace(/""/g,'"')}for(;a1)r=function(t,e,n){var r,i=[],o=[];function a(t){var e=t<0?~t:t;(o[e]||(o[e]=[])).push({i:t,g:r})}function s(t){t.forEach(a)}function u(t){t.forEach(s)}function l(t){t.forEach(u)}function c(t){switch(r=t,t.type){case"GeometryCollection":t.geometries.forEach(c);break;case"LineString":s(t.arcs);break;case"MultiLineString":case"Polygon":u(t.arcs);break;case"MultiPolygon":l(t.arcs)}}return c(e),o.forEach(null==n?function(t){i.push(t[0].i)}:function(t){n(t[0].g,t[t.length-1].g)&&i.push(t[0].i)}),i}(0,e,n);else for(i=0,r=new Array(o=t.arcs.length);ie?1:t>=e?0:NaN}function te(t,e){return null==t||null==e?NaN:et?1:e>=t?0:NaN}function ee(t){let e,n,r;function i(t,r){let i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0,o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:t.length;if(i>>1;n(t[e],r)<0?i=e+1:o=e}while(iKt(t(e),n),r=(e,n)=>t(e)-n):(e=t===Kt||t===te?t:ne,n=t,r=t),{left:i,center:function(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;const o=i(t,e,n,(arguments.length>3&&void 0!==arguments[3]?arguments[3]:t.length)-1);return o>n&&r(t[o-1],e)>-r(t[o],e)?o-1:o},right:function(t,r){let i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0,o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:t.length;if(i>>1;n(t[e],r)<=0?i=e+1:o=e}while(i0){for(o=t[--i];i>0&&(e=o,n=t[--i],o=e+n,r=n-(o-e),!r););i>0&&(r<0&&t[i-1]<0||r>0&&t[i-1]>0)&&(n=2*r,e=o+n,n==e-o&&(o=e))}return o}}class ue extends Map{constructor(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:de;if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:e}}),null!=t)for(const[e,n]of t)this.set(e,n)}get(t){return super.get(ce(this,t))}has(t){return super.has(ce(this,t))}set(t,e){return super.set(fe(this,t),e)}delete(t){return super.delete(he(this,t))}}class le extends Set{constructor(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:de;if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:e}}),null!=t)for(const e of t)this.add(e)}has(t){return super.has(ce(this,t))}add(t){return super.add(fe(this,t))}delete(t){return super.delete(he(this,t))}}function ce(t,e){let{_intern:n,_key:r}=t;const i=r(e);return n.has(i)?n.get(i):e}function fe(t,e){let{_intern:n,_key:r}=t;const i=r(e);return n.has(i)?n.get(i):(n.set(i,e),e)}function he(t,e){let{_intern:n,_key:r}=t;const i=r(e);return n.has(i)&&(e=n.get(i),n.delete(i)),e}function de(t){return null!==t&&"object"==typeof t?t.valueOf():t}function pe(t,e){return(null==t||!(t>=t))-(null==e||!(e>=e))||(te?1:0)}const ge=Math.sqrt(50),me=Math.sqrt(10),ye=Math.sqrt(2);function ve(t,e,n){const r=(e-t)/Math.max(0,n),i=Math.floor(Math.log10(r)),o=r/Math.pow(10,i),a=o>=ge?10:o>=me?5:o>=ye?2:1;let s,u,l;return i<0?(l=Math.pow(10,-i)/a,s=Math.round(t*l),u=Math.round(e*l),s/le&&--u,l=-l):(l=Math.pow(10,i)*a,s=Math.round(t/l),u=Math.round(e/l),s*le&&--u),u0))return[];if((t=+t)===(e=+e))return[t];const r=e=i))return[];const s=o-i+1,u=new Array(s);if(r)if(a<0)for(let t=0;t=e)&&(n=e);else{let r=-1;for(let i of t)null!=(i=e(i,++r,t))&&(n=i)&&(n=i)}return n}function ke(t,e){let n;if(void 0===e)for(const e of t)null!=e&&(n>e||void 0===n&&e>=e)&&(n=e);else{let r=-1;for(let i of t)null!=(i=e(i,++r,t))&&(n>i||void 0===n&&i>=i)&&(n=i)}return n}function Ae(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1/0,i=arguments.length>4?arguments[4]:void 0;if(e=Math.floor(e),n=Math.floor(Math.max(0,n)),r=Math.floor(Math.min(t.length-1,r)),!(n<=e&&e<=r))return t;for(i=void 0===i?pe:function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:Kt;if(t===Kt)return pe;if("function"!=typeof t)throw new TypeError("compare is not a function");return(e,n)=>{const r=t(e,n);return r||0===r?r:(0===t(n,n))-(0===t(e,e))}}(i);r>n;){if(r-n>600){const o=r-n+1,a=e-n+1,s=Math.log(o),u=.5*Math.exp(2*s/3),l=.5*Math.sqrt(s*u*(o-u)/o)*(a-o/2<0?-1:1);Ae(t,e,Math.max(n,Math.floor(e-a*u/o+l)),Math.min(r,Math.floor(e+(o-a)*u/o+l)),i)}const o=t[e];let a=n,s=r;for(Me(t,n,e),i(t[r],o)>0&&Me(t,n,r);a0;)--s}0===i(t[n],o)?Me(t,n,s):(++s,Me(t,s,r)),s<=e&&(n=s+1),e<=s&&(r=s-1)}return t}function Me(t,e,n){const r=t[e];t[e]=t[n],t[n]=r}function Ee(t,e,n){if(t=Float64Array.from(function*(t,e){if(void 0===e)for(let e of t)null!=e&&(e=+e)>=e&&(yield e);else{let n=-1;for(let r of t)null!=(r=e(r,++n,t))&&(r=+r)>=r&&(yield r)}}(t,n)),(r=t.length)&&!isNaN(e=+e)){if(e<=0||r<2)return ke(t);if(e>=1)return we(t);var r,i=(r-1)*e,o=Math.floor(i),a=we(Ae(t,o).subarray(0,o+1));return a+(ke(t.subarray(o+1))-a)*(i-o)}}function De(t,e){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:re;if((r=t.length)&&!isNaN(e=+e)){if(e<=0||r<2)return+n(t[0],0,t);if(e>=1)return+n(t[r-1],r-1,t);var r,i=(r-1)*e,o=Math.floor(i),a=+n(t[o],o,t);return a+(+n(t[o+1],o+1,t)-a)*(i-o)}}function Ce(t,e){return Ee(t,.5,e)}function Fe(t){return Array.from(function*(t){for(const e of t)yield*e}(t))}function Se(t,e,n){t=+t,e=+e,n=(i=arguments.length)<2?(e=t,t=0,1):i<3?1:+n;for(var r=-1,i=0|Math.max(0,Math.ceil((e-t)/n)),o=new Array(i);++r1?r[0]+r.slice(2):r,+t.slice(n+1)]}function ze(t){return(t=Be(Math.abs(t)))?t[1]:NaN}var Ne,Oe=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Re(t){if(!(e=Oe.exec(t)))throw new Error("invalid format: "+t);var e;return new Ue({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}function Ue(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}function Le(t,e){var n=Be(t,e);if(!n)return t+"";var r=n[0],i=n[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")}Re.prototype=Ue.prototype,Ue.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var qe={"%":(t,e)=>(100*t).toFixed(e),b:t=>Math.round(t).toString(2),c:t=>t+"",d:function(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)},e:(t,e)=>t.toExponential(e),f:(t,e)=>t.toFixed(e),g:(t,e)=>t.toPrecision(e),o:t=>Math.round(t).toString(8),p:(t,e)=>Le(100*t,e),r:Le,s:function(t,e){var n=Be(t,e);if(!n)return t+"";var r=n[0],i=n[1],o=i-(Ne=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,a=r.length;return o===a?r:o>a?r+new Array(o-a+1).join("0"):o>0?r.slice(0,o)+"."+r.slice(o):"0."+new Array(1-o).join("0")+Be(t,Math.max(0,e+o-1))[0]},X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function Pe(t){return t}var je,Ie,We,He=Array.prototype.map,Ye=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function Ge(t){var e,n,r=void 0===t.grouping||void 0===t.thousands?Pe:(e=He.call(t.grouping,Number),n=t.thousands+"",function(t,r){for(var i=t.length,o=[],a=0,s=e[0],u=0;i>0&&s>0&&(u+s+1>r&&(s=Math.max(1,r-u)),o.push(t.substring(i-=s,i+s)),!((u+=s+1)>r));)s=e[a=(a+1)%e.length];return o.reverse().join(n)}),i=void 0===t.currency?"":t.currency[0]+"",o=void 0===t.currency?"":t.currency[1]+"",a=void 0===t.decimal?".":t.decimal+"",s=void 0===t.numerals?Pe:function(t){return function(e){return e.replace(/[0-9]/g,(function(e){return t[+e]}))}}(He.call(t.numerals,String)),u=void 0===t.percent?"%":t.percent+"",l=void 0===t.minus?"−":t.minus+"",c=void 0===t.nan?"NaN":t.nan+"";function f(t){var e=(t=Re(t)).fill,n=t.align,f=t.sign,h=t.symbol,d=t.zero,p=t.width,g=t.comma,m=t.precision,y=t.trim,v=t.type;"n"===v?(g=!0,v="g"):qe[v]||(void 0===m&&(m=12),y=!0,v="g"),(d||"0"===e&&"="===n)&&(d=!0,e="0",n="=");var _="$"===h?i:"#"===h&&/[boxX]/.test(v)?"0"+v.toLowerCase():"",x="$"===h?o:/[%p]/.test(v)?u:"",b=qe[v],w=/[defgprs%]/.test(v);function k(t){var i,o,u,h=_,k=x;if("c"===v)k=b(t)+k,t="";else{var A=(t=+t)<0||1/t<0;if(t=isNaN(t)?c:b(Math.abs(t),m),y&&(t=function(t){t:for(var e,n=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(e+1):t}(t)),A&&0==+t&&"+"!==f&&(A=!1),h=(A?"("===f?f:l:"-"===f||"("===f?"":f)+h,k=("s"===v?Ye[8+Ne/3]:"")+k+(A&&"("===f?")":""),w)for(i=-1,o=t.length;++i(u=t.charCodeAt(i))||u>57){k=(46===u?a+t.slice(i+1):t.slice(i))+k,t=t.slice(0,i);break}}g&&!d&&(t=r(t,1/0));var M=h.length+t.length+k.length,E=M>1)+h+t+k+E.slice(M);break;default:t=E+h+t+k}return s(t)}return m=void 0===m?6:/[gprs]/.test(v)?Math.max(1,Math.min(21,m)):Math.max(0,Math.min(20,m)),k.toString=function(){return t+""},k}return{format:f,formatPrefix:function(t,e){var n=f(((t=Re(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(ze(e)/3))),i=Math.pow(10,-r),o=Ye[8+r/3];return function(t){return n(i*t)+o}}}}function Ve(t){return Math.max(0,-ze(Math.abs(t)))}function Xe(t,e){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(ze(e)/3)))-ze(Math.abs(t)))}function Je(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,ze(e)-ze(t))+1}!function(t){je=Ge(t),Ie=je.format,We=je.formatPrefix}({thousands:",",grouping:[3],currency:["$",""]});const Ze=new Date,Qe=new Date;function Ke(t,e,n,r){function i(e){return t(e=0===arguments.length?new Date:new Date(+e)),e}return i.floor=e=>(t(e=new Date(+e)),e),i.ceil=n=>(t(n=new Date(n-1)),e(n,1),t(n),n),i.round=t=>{const e=i(t),n=i.ceil(t);return t-e(e(t=new Date(+t),null==n?1:Math.floor(n)),t),i.range=(n,r,o)=>{const a=[];if(n=i.ceil(n),o=null==o?1:Math.floor(o),!(n0))return a;let s;do{a.push(s=new Date(+n)),e(n,o),t(n)}while(sKe((e=>{if(e>=e)for(;t(e),!n(e);)e.setTime(e-1)}),((t,r)=>{if(t>=t)if(r<0)for(;++r<=0;)for(;e(t,-1),!n(t););else for(;--r>=0;)for(;e(t,1),!n(t););})),n&&(i.count=(e,r)=>(Ze.setTime(+e),Qe.setTime(+r),t(Ze),t(Qe),Math.floor(n(Ze,Qe))),i.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?i.filter(r?e=>r(e)%t==0:e=>i.count(0,e)%t==0):i:null)),i}const tn=Ke((()=>{}),((t,e)=>{t.setTime(+t+e)}),((t,e)=>e-t));tn.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?Ke((e=>{e.setTime(Math.floor(e/t)*t)}),((e,n)=>{e.setTime(+e+n*t)}),((e,n)=>(n-e)/t)):tn:null),tn.range;const en=1e3,nn=6e4,rn=36e5,on=864e5,an=6048e5,sn=2592e6,un=31536e6,ln=Ke((t=>{t.setTime(t-t.getMilliseconds())}),((t,e)=>{t.setTime(+t+e*en)}),((t,e)=>(e-t)/en),(t=>t.getUTCSeconds()));ln.range;const cn=Ke((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*en)}),((t,e)=>{t.setTime(+t+e*nn)}),((t,e)=>(e-t)/nn),(t=>t.getMinutes()));cn.range;const fn=Ke((t=>{t.setUTCSeconds(0,0)}),((t,e)=>{t.setTime(+t+e*nn)}),((t,e)=>(e-t)/nn),(t=>t.getUTCMinutes()));fn.range;const hn=Ke((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*en-t.getMinutes()*nn)}),((t,e)=>{t.setTime(+t+e*rn)}),((t,e)=>(e-t)/rn),(t=>t.getHours()));hn.range;const dn=Ke((t=>{t.setUTCMinutes(0,0,0)}),((t,e)=>{t.setTime(+t+e*rn)}),((t,e)=>(e-t)/rn),(t=>t.getUTCHours()));dn.range;const pn=Ke((t=>t.setHours(0,0,0,0)),((t,e)=>t.setDate(t.getDate()+e)),((t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*nn)/on),(t=>t.getDate()-1));pn.range;const gn=Ke((t=>{t.setUTCHours(0,0,0,0)}),((t,e)=>{t.setUTCDate(t.getUTCDate()+e)}),((t,e)=>(e-t)/on),(t=>t.getUTCDate()-1));gn.range;const mn=Ke((t=>{t.setUTCHours(0,0,0,0)}),((t,e)=>{t.setUTCDate(t.getUTCDate()+e)}),((t,e)=>(e-t)/on),(t=>Math.floor(t/on)));function yn(t){return Ke((e=>{e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)}),((t,e)=>{t.setDate(t.getDate()+7*e)}),((t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*nn)/an))}mn.range;const vn=yn(0),_n=yn(1),xn=yn(2),bn=yn(3),wn=yn(4),kn=yn(5),An=yn(6);function Mn(t){return Ke((e=>{e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)}),((t,e)=>{t.setUTCDate(t.getUTCDate()+7*e)}),((t,e)=>(e-t)/an))}vn.range,_n.range,xn.range,bn.range,wn.range,kn.range,An.range;const En=Mn(0),Dn=Mn(1),Cn=Mn(2),Fn=Mn(3),Sn=Mn(4),$n=Mn(5),Tn=Mn(6);En.range,Dn.range,Cn.range,Fn.range,Sn.range,$n.range,Tn.range;const Bn=Ke((t=>{t.setDate(1),t.setHours(0,0,0,0)}),((t,e)=>{t.setMonth(t.getMonth()+e)}),((t,e)=>e.getMonth()-t.getMonth()+12*(e.getFullYear()-t.getFullYear())),(t=>t.getMonth()));Bn.range;const zn=Ke((t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)}),((t,e)=>{t.setUTCMonth(t.getUTCMonth()+e)}),((t,e)=>e.getUTCMonth()-t.getUTCMonth()+12*(e.getUTCFullYear()-t.getUTCFullYear())),(t=>t.getUTCMonth()));zn.range;const Nn=Ke((t=>{t.setMonth(0,1),t.setHours(0,0,0,0)}),((t,e)=>{t.setFullYear(t.getFullYear()+e)}),((t,e)=>e.getFullYear()-t.getFullYear()),(t=>t.getFullYear()));Nn.every=t=>isFinite(t=Math.floor(t))&&t>0?Ke((e=>{e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)}),((e,n)=>{e.setFullYear(e.getFullYear()+n*t)})):null,Nn.range;const On=Ke((t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)}),((t,e)=>{t.setUTCFullYear(t.getUTCFullYear()+e)}),((t,e)=>e.getUTCFullYear()-t.getUTCFullYear()),(t=>t.getUTCFullYear()));function Rn(t,e,n,r,i,o){const a=[[ln,1,en],[ln,5,5e3],[ln,15,15e3],[ln,30,3e4],[o,1,nn],[o,5,3e5],[o,15,9e5],[o,30,18e5],[i,1,rn],[i,3,108e5],[i,6,216e5],[i,12,432e5],[r,1,on],[r,2,1728e5],[n,1,an],[e,1,sn],[e,3,7776e6],[t,1,un]];function s(e,n,r){const i=Math.abs(n-e)/r,o=ee((t=>{let[,,e]=t;return e})).right(a,i);if(o===a.length)return t.every(be(e/un,n/un,r));if(0===o)return tn.every(Math.max(be(e,n,r),1));const[s,u]=a[i/a[o-1][2]isFinite(t=Math.floor(t))&&t>0?Ke((e=>{e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)}),((e,n)=>{e.setUTCFullYear(e.getUTCFullYear()+n*t)})):null,On.range;const[Un,Ln]=Rn(On,zn,En,mn,dn,fn),[qn,Pn]=Rn(Nn,Bn,vn,pn,hn,cn),jn="year",In="quarter",Wn="month",Hn="week",Yn="date",Gn="day",Vn="dayofyear",Xn="hours",Jn="minutes",Zn="seconds",Qn="milliseconds",Kn=[jn,In,Wn,Hn,Yn,Gn,Vn,Xn,Jn,Zn,Qn],tr=Kn.reduce(((t,e,n)=>(t[e]=1+n,t)),{});function er(t){const e=V(t).slice(),n={};e.length||s("Missing time unit."),e.forEach((t=>{lt(tr,t)?n[t]=1:s(`Invalid time unit: ${t}.`)}));return(n[Hn]||n[Gn]?1:0)+(n[In]||n[Wn]||n[Yn]?1:0)+(n[Vn]?1:0)>1&&s(`Incompatible time units: ${t}`),e.sort(((t,e)=>tr[t]-tr[e])),e}const nr={[jn]:"%Y ",[In]:"Q%q ",[Wn]:"%b ",[Yn]:"%d ",[Hn]:"W%U ",[Gn]:"%a ",[Vn]:"%j ",[Xn]:"%H:00",[Jn]:"00:%M",[Zn]:":%S",[Qn]:".%L",[`${jn}-${Wn}`]:"%Y-%m ",[`${jn}-${Wn}-${Yn}`]:"%Y-%m-%d ",[`${Xn}-${Jn}`]:"%H:%M"};function rr(t,e){const n=ot({},nr,e),r=er(t),i=r.length;let o,a,s="",u=0;for(u=0;uu;--o)if(a=r.slice(u,o).join("-"),null!=n[a]){s+=n[a],u=o;break}return s.trim()}const ir=new Date;function or(t){return ir.setFullYear(t),ir.setMonth(0),ir.setDate(1),ir.setHours(0,0,0,0),ir}function ar(t){return ur(new Date(t))}function sr(t){return lr(new Date(t))}function ur(t){return pn.count(or(t.getFullYear())-1,t)}function lr(t){return vn.count(or(t.getFullYear())-1,t)}function cr(t){return or(t).getDay()}function fr(t,e,n,r,i,o,a){if(0<=t&&t<100){const s=new Date(-1,e,n,r,i,o,a);return s.setFullYear(t),s}return new Date(t,e,n,r,i,o,a)}function hr(t){return pr(new Date(t))}function dr(t){return gr(new Date(t))}function pr(t){const e=Date.UTC(t.getUTCFullYear(),0,1);return gn.count(e-1,t)}function gr(t){const e=Date.UTC(t.getUTCFullYear(),0,1);return En.count(e-1,t)}function mr(t){return ir.setTime(Date.UTC(t,0,1)),ir.getUTCDay()}function yr(t,e,n,r,i,o,a){if(0<=t&&t<100){const t=new Date(Date.UTC(-1,e,n,r,i,o,a));return t.setUTCFullYear(n.y),t}return new Date(Date.UTC(t,e,n,r,i,o,a))}function vr(t,e,n,r,i){const o=e||1,a=F(t),s=(t,e,i)=>function(t,e,n,r){const i=n<=1?t:r?(e,i)=>r+n*Math.floor((t(e,i)-r)/n):(e,r)=>n*Math.floor(t(e,r)/n);return e?(t,n)=>e(i(t,n),n):i}(n[i=i||t],r[i],t===a&&o,e),u=new Date,l=Bt(t),c=l[jn]?s(jn):rt(2012),f=l[Wn]?s(Wn):l[In]?s(In):h,p=l[Hn]&&l[Gn]?s(Gn,1,Hn+Gn):l[Hn]?s(Hn,1):l[Gn]?s(Gn,1):l[Yn]?s(Yn,1):l[Vn]?s(Vn,1):d,g=l[Xn]?s(Xn):h,m=l[Jn]?s(Jn):h,y=l[Zn]?s(Zn):h,v=l[Qn]?s(Qn):h;return function(t){u.setTime(+t);const e=c(u);return i(e,f(u),p(u,e),g(u),m(u),y(u),v(u))}}function _r(t,e,n){return e+7*t-(n+6)%7}const xr={[jn]:t=>t.getFullYear(),[In]:t=>Math.floor(t.getMonth()/3),[Wn]:t=>t.getMonth(),[Yn]:t=>t.getDate(),[Xn]:t=>t.getHours(),[Jn]:t=>t.getMinutes(),[Zn]:t=>t.getSeconds(),[Qn]:t=>t.getMilliseconds(),[Vn]:t=>ur(t),[Hn]:t=>lr(t),[Hn+Gn]:(t,e)=>_r(lr(t),t.getDay(),cr(e)),[Gn]:(t,e)=>_r(1,t.getDay(),cr(e))},br={[In]:t=>3*t,[Hn]:(t,e)=>_r(t,0,cr(e))};function wr(t,e){return vr(t,e||1,xr,br,fr)}const kr={[jn]:t=>t.getUTCFullYear(),[In]:t=>Math.floor(t.getUTCMonth()/3),[Wn]:t=>t.getUTCMonth(),[Yn]:t=>t.getUTCDate(),[Xn]:t=>t.getUTCHours(),[Jn]:t=>t.getUTCMinutes(),[Zn]:t=>t.getUTCSeconds(),[Qn]:t=>t.getUTCMilliseconds(),[Vn]:t=>pr(t),[Hn]:t=>gr(t),[Gn]:(t,e)=>_r(1,t.getUTCDay(),mr(e)),[Hn+Gn]:(t,e)=>_r(gr(t),t.getUTCDay(),mr(e))},Ar={[In]:t=>3*t,[Hn]:(t,e)=>_r(t,0,mr(e))};function Mr(t,e){return vr(t,e||1,kr,Ar,yr)}const Er={[jn]:Nn,[In]:Bn.every(3),[Wn]:Bn,[Hn]:vn,[Yn]:pn,[Gn]:pn,[Vn]:pn,[Xn]:hn,[Jn]:cn,[Zn]:ln,[Qn]:tn},Dr={[jn]:On,[In]:zn.every(3),[Wn]:zn,[Hn]:En,[Yn]:gn,[Gn]:gn,[Vn]:gn,[Xn]:dn,[Jn]:fn,[Zn]:ln,[Qn]:tn};function Cr(t){return Er[t]}function Fr(t){return Dr[t]}function Sr(t,e,n){return t?t.offset(e,n):void 0}function $r(t,e,n){return Sr(Cr(t),e,n)}function Tr(t,e,n){return Sr(Fr(t),e,n)}function Br(t,e,n,r){return t?t.range(e,n,r):void 0}function zr(t,e,n,r){return Br(Cr(t),e,n,r)}function Nr(t,e,n,r){return Br(Fr(t),e,n,r)}const Or=1e3,Rr=6e4,Ur=36e5,Lr=864e5,qr=2592e6,Pr=31536e6,jr=[jn,Wn,Yn,Xn,Jn,Zn,Qn],Ir=jr.slice(0,-1),Wr=Ir.slice(0,-1),Hr=Wr.slice(0,-1),Yr=Hr.slice(0,-1),Gr=[jn,Wn],Vr=[jn],Xr=[[Ir,1,Or],[Ir,5,5e3],[Ir,15,15e3],[Ir,30,3e4],[Wr,1,Rr],[Wr,5,3e5],[Wr,15,9e5],[Wr,30,18e5],[Hr,1,Ur],[Hr,3,108e5],[Hr,6,216e5],[Hr,12,432e5],[Yr,1,Lr],[[jn,Hn],1,6048e5],[Gr,1,qr],[Gr,3,7776e6],[Vr,1,Pr]];function Jr(t){const e=t.extent,n=t.maxbins||40,r=Math.abs(Dt(e))/n;let i,o,a=ee((t=>t[2])).right(Xr,r);return a===Xr.length?(i=Vr,o=be(e[0]/Pr,e[1]/Pr,n)):a?(a=Xr[r/Xr[a-1][2]=12)]},q:function(t){return 1+~~(t.getMonth()/3)},Q:wo,s:ko,S:ji,u:Ii,U:Wi,V:Yi,w:Gi,W:Vi,x:null,X:null,y:Xi,Y:Zi,Z:Ki,"%":bo},x={a:function(t){return a[t.getUTCDay()]},A:function(t){return o[t.getUTCDay()]},b:function(t){return u[t.getUTCMonth()]},B:function(t){return s[t.getUTCMonth()]},c:null,d:to,e:to,f:oo,g:yo,G:_o,H:eo,I:no,j:ro,L:io,m:ao,M:so,p:function(t){return i[+(t.getUTCHours()>=12)]},q:function(t){return 1+~~(t.getUTCMonth()/3)},Q:wo,s:ko,S:uo,u:lo,U:co,V:ho,w:po,W:go,x:null,X:null,y:mo,Y:vo,Z:xo,"%":bo},b={a:function(t,e,n){var r=d.exec(e.slice(n));return r?(t.w=p.get(r[0].toLowerCase()),n+r[0].length):-1},A:function(t,e,n){var r=f.exec(e.slice(n));return r?(t.w=h.get(r[0].toLowerCase()),n+r[0].length):-1},b:function(t,e,n){var r=y.exec(e.slice(n));return r?(t.m=v.get(r[0].toLowerCase()),n+r[0].length):-1},B:function(t,e,n){var r=g.exec(e.slice(n));return r?(t.m=m.get(r[0].toLowerCase()),n+r[0].length):-1},c:function(t,n,r){return A(t,e,n,r)},d:Ai,e:Ai,f:Si,g:xi,G:_i,H:Ei,I:Ei,j:Mi,L:Fi,m:ki,M:Di,p:function(t,e,n){var r=l.exec(e.slice(n));return r?(t.p=c.get(r[0].toLowerCase()),n+r[0].length):-1},q:wi,Q:Ti,s:Bi,S:Ci,u:gi,U:mi,V:yi,w:pi,W:vi,x:function(t,e,r){return A(t,n,e,r)},X:function(t,e,n){return A(t,r,e,n)},y:xi,Y:_i,Z:bi,"%":$i};function w(t,e){return function(n){var r,i,o,a=[],s=-1,u=0,l=t.length;for(n instanceof Date||(n=new Date(+n));++s53)return null;"w"in o||(o.w=1),"Z"in o?(i=(r=Qr(Kr(o.y,0,1))).getUTCDay(),r=i>4||0===i?Dn.ceil(r):Dn(r),r=gn.offset(r,7*(o.V-1)),o.y=r.getUTCFullYear(),o.m=r.getUTCMonth(),o.d=r.getUTCDate()+(o.w+6)%7):(i=(r=Zr(Kr(o.y,0,1))).getDay(),r=i>4||0===i?_n.ceil(r):_n(r),r=pn.offset(r,7*(o.V-1)),o.y=r.getFullYear(),o.m=r.getMonth(),o.d=r.getDate()+(o.w+6)%7)}else("W"in o||"U"in o)&&("w"in o||(o.w="u"in o?o.u%7:"W"in o?1:0),i="Z"in o?Qr(Kr(o.y,0,1)).getUTCDay():Zr(Kr(o.y,0,1)).getDay(),o.m=0,o.d="W"in o?(o.w+6)%7+7*o.W-(i+5)%7:o.w+7*o.U-(i+6)%7);return"Z"in o?(o.H+=o.Z/100|0,o.M+=o.Z%100,Qr(o)):Zr(o)}}function A(t,e,n,r){for(var i,o,a=0,s=e.length,u=n.length;a=u)return-1;if(37===(i=e.charCodeAt(a++))){if(i=e.charAt(a++),!(o=b[i in ai?e.charAt(a++):i])||(r=o(t,n,r))<0)return-1}else if(i!=n.charCodeAt(r++))return-1}return r}return _.x=w(n,_),_.X=w(r,_),_.c=w(e,_),x.x=w(n,x),x.X=w(r,x),x.c=w(e,x),{format:function(t){var e=w(t+="",_);return e.toString=function(){return t},e},parse:function(t){var e=k(t+="",!1);return e.toString=function(){return t},e},utcFormat:function(t){var e=w(t+="",x);return e.toString=function(){return t},e},utcParse:function(t){var e=k(t+="",!0);return e.toString=function(){return t},e}}}var ei,ni,ri,ii,oi,ai={"-":"",_:" ",0:"0"},si=/^\s*\d+/,ui=/^%/,li=/[\\^$*+?|[\]().{}]/g;function ci(t,e,n){var r=t<0?"-":"",i=(r?-t:t)+"",o=i.length;return r+(o[t.toLowerCase(),e])))}function pi(t,e,n){var r=si.exec(e.slice(n,n+1));return r?(t.w=+r[0],n+r[0].length):-1}function gi(t,e,n){var r=si.exec(e.slice(n,n+1));return r?(t.u=+r[0],n+r[0].length):-1}function mi(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.U=+r[0],n+r[0].length):-1}function yi(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.V=+r[0],n+r[0].length):-1}function vi(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.W=+r[0],n+r[0].length):-1}function _i(t,e,n){var r=si.exec(e.slice(n,n+4));return r?(t.y=+r[0],n+r[0].length):-1}function xi(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.y=+r[0]+(+r[0]>68?1900:2e3),n+r[0].length):-1}function bi(t,e,n){var r=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(e.slice(n,n+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||"00")),n+r[0].length):-1}function wi(t,e,n){var r=si.exec(e.slice(n,n+1));return r?(t.q=3*r[0]-3,n+r[0].length):-1}function ki(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.m=r[0]-1,n+r[0].length):-1}function Ai(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.d=+r[0],n+r[0].length):-1}function Mi(t,e,n){var r=si.exec(e.slice(n,n+3));return r?(t.m=0,t.d=+r[0],n+r[0].length):-1}function Ei(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.H=+r[0],n+r[0].length):-1}function Di(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.M=+r[0],n+r[0].length):-1}function Ci(t,e,n){var r=si.exec(e.slice(n,n+2));return r?(t.S=+r[0],n+r[0].length):-1}function Fi(t,e,n){var r=si.exec(e.slice(n,n+3));return r?(t.L=+r[0],n+r[0].length):-1}function Si(t,e,n){var r=si.exec(e.slice(n,n+6));return r?(t.L=Math.floor(r[0]/1e3),n+r[0].length):-1}function $i(t,e,n){var r=ui.exec(e.slice(n,n+1));return r?n+r[0].length:-1}function Ti(t,e,n){var r=si.exec(e.slice(n));return r?(t.Q=+r[0],n+r[0].length):-1}function Bi(t,e,n){var r=si.exec(e.slice(n));return r?(t.s=+r[0],n+r[0].length):-1}function zi(t,e){return ci(t.getDate(),e,2)}function Ni(t,e){return ci(t.getHours(),e,2)}function Oi(t,e){return ci(t.getHours()%12||12,e,2)}function Ri(t,e){return ci(1+pn.count(Nn(t),t),e,3)}function Ui(t,e){return ci(t.getMilliseconds(),e,3)}function Li(t,e){return Ui(t,e)+"000"}function qi(t,e){return ci(t.getMonth()+1,e,2)}function Pi(t,e){return ci(t.getMinutes(),e,2)}function ji(t,e){return ci(t.getSeconds(),e,2)}function Ii(t){var e=t.getDay();return 0===e?7:e}function Wi(t,e){return ci(vn.count(Nn(t)-1,t),e,2)}function Hi(t){var e=t.getDay();return e>=4||0===e?wn(t):wn.ceil(t)}function Yi(t,e){return t=Hi(t),ci(wn.count(Nn(t),t)+(4===Nn(t).getDay()),e,2)}function Gi(t){return t.getDay()}function Vi(t,e){return ci(_n.count(Nn(t)-1,t),e,2)}function Xi(t,e){return ci(t.getFullYear()%100,e,2)}function Ji(t,e){return ci((t=Hi(t)).getFullYear()%100,e,2)}function Zi(t,e){return ci(t.getFullYear()%1e4,e,4)}function Qi(t,e){var n=t.getDay();return ci((t=n>=4||0===n?wn(t):wn.ceil(t)).getFullYear()%1e4,e,4)}function Ki(t){var e=t.getTimezoneOffset();return(e>0?"-":(e*=-1,"+"))+ci(e/60|0,"0",2)+ci(e%60,"0",2)}function to(t,e){return ci(t.getUTCDate(),e,2)}function eo(t,e){return ci(t.getUTCHours(),e,2)}function no(t,e){return ci(t.getUTCHours()%12||12,e,2)}function ro(t,e){return ci(1+gn.count(On(t),t),e,3)}function io(t,e){return ci(t.getUTCMilliseconds(),e,3)}function oo(t,e){return io(t,e)+"000"}function ao(t,e){return ci(t.getUTCMonth()+1,e,2)}function so(t,e){return ci(t.getUTCMinutes(),e,2)}function uo(t,e){return ci(t.getUTCSeconds(),e,2)}function lo(t){var e=t.getUTCDay();return 0===e?7:e}function co(t,e){return ci(En.count(On(t)-1,t),e,2)}function fo(t){var e=t.getUTCDay();return e>=4||0===e?Sn(t):Sn.ceil(t)}function ho(t,e){return t=fo(t),ci(Sn.count(On(t),t)+(4===On(t).getUTCDay()),e,2)}function po(t){return t.getUTCDay()}function go(t,e){return ci(Dn.count(On(t)-1,t),e,2)}function mo(t,e){return ci(t.getUTCFullYear()%100,e,2)}function yo(t,e){return ci((t=fo(t)).getUTCFullYear()%100,e,2)}function vo(t,e){return ci(t.getUTCFullYear()%1e4,e,4)}function _o(t,e){var n=t.getUTCDay();return ci((t=n>=4||0===n?Sn(t):Sn.ceil(t)).getUTCFullYear()%1e4,e,4)}function xo(){return"+0000"}function bo(){return"%"}function wo(t){return+t}function ko(t){return Math.floor(+t/1e3)}function Ao(t){const e={};return n=>e[n]||(e[n]=t(n))}function Mo(t){const e=Ao(t.format),n=t.formatPrefix;return{format:e,formatPrefix:n,formatFloat(t){const n=Re(t||",");if(null==n.precision){switch(n.precision=12,n.type){case"%":n.precision-=2;break;case"e":n.precision-=1}return r=e(n),i=e(".1f")(1)[1],t=>{const e=r(t),n=e.indexOf(i);if(n<0)return e;let o=function(t,e){let n,r=t.lastIndexOf("e");if(r>0)return r;for(r=t.length;--r>e;)if(n=t.charCodeAt(r),n>=48&&n<=57)return r+1}(e,n);const a=on;)if("0"!==e[o]){++o;break}return e.slice(0,o)+a}}return e(n);var r,i},formatSpan(t,r,i,o){o=Re(null==o?",f":o);const a=be(t,r,i),s=Math.max(Math.abs(t),Math.abs(r));let u;if(null==o.precision)switch(o.type){case"s":return isNaN(u=Xe(a,s))||(o.precision=u),n(o,s);case"":case"e":case"g":case"p":case"r":isNaN(u=Je(a,s))||(o.precision=u-("e"===o.type));break;case"f":case"%":isNaN(u=Ve(a))||(o.precision=u-2*("%"===o.type))}return e(o)}}}let Eo,Do;function Co(){return Eo=Mo({format:Ie,formatPrefix:We})}function Fo(t){return Mo(Ge(t))}function So(t){return arguments.length?Eo=Fo(t):Eo}function $o(t,e,n){A(n=n||{})||s(`Invalid time multi-format specifier: ${n}`);const r=e(Zn),i=e(Jn),o=e(Xn),a=e(Yn),u=e(Hn),l=e(Wn),c=e(In),f=e(jn),h=t(n[Qn]||".%L"),d=t(n[Zn]||":%S"),p=t(n[Jn]||"%I:%M"),g=t(n[Xn]||"%I %p"),m=t(n[Yn]||n[Gn]||"%a %d"),y=t(n[Hn]||"%b %d"),v=t(n[Wn]||"%B"),_=t(n[In]||"%B"),x=t(n[jn]||"%Y");return t=>(r(t)xt(t)?e(t):$o(e,Cr,t),utcFormat:t=>xt(t)?n(t):$o(n,Fr,t),timeParse:Ao(t.parse),utcParse:Ao(t.utcParse)}}function Bo(){return Do=To({format:ni,parse:ri,utcFormat:ii,utcParse:oi})}function zo(t){return To(ti(t))}function No(t){return arguments.length?Do=zo(t):Do}!function(t){ei=ti(t),ni=ei.format,ri=ei.parse,ii=ei.utcFormat,oi=ei.utcParse}({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]}),Co(),Bo();const Oo=(t,e)=>ot({},t,e);function Ro(t,e){const n=t?Fo(t):So(),r=e?zo(e):No();return Oo(n,r)}function Uo(t,e){const n=arguments.length;return n&&2!==n&&s("defaultLocale expects either zero or two arguments."),n?Oo(So(t),No(e)):Oo(So(),No())}const Lo=/^(data:|([A-Za-z]+:)?\/\/)/,qo=/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|file|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,Po=/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g,jo="file://";async function Io(t,e){const n=await this.sanitize(t,e),r=n.href;return n.localFile?this.file(r):this.http(r,e)}async function Wo(t,e){e=ot({},this.options,e);const n=this.fileAccess,r={href:null};let i,o,a;const u=qo.test(t.replace(Po,""));null!=t&&"string"==typeof t&&u||s("Sanitize failure, invalid URI: "+Ct(t));const l=Lo.test(t);return(a=e.baseURL)&&!l&&(t.startsWith("/")||a.endsWith("/")||(t="/"+t),t=a+t),o=(i=t.startsWith(jo))||"file"===e.mode||"http"!==e.mode&&!l&&n,i?t=t.slice(jo.length):t.startsWith("//")&&("file"===e.defaultProtocol?(t=t.slice(2),o=!0):t=(e.defaultProtocol||"http")+":"+t),Object.defineProperty(r,"localFile",{value:!!o}),r.href=t,e.target&&(r.target=e.target+""),e.rel&&(r.rel=e.rel+""),"image"===e.context&&e.crossOrigin&&(r.crossOrigin=e.crossOrigin+""),r}function Ho(t){return t?e=>new Promise(((n,r)=>{t.readFile(e,((t,e)=>{t?r(t):n(e)}))})):Yo}async function Yo(){s("No file system access.")}function Go(t){return t?async function(e,n){const r=ot({},this.options.http,n),i=n&&n.response,o=await t(e,r);return o.ok?J(o[i])?o[i]():o.text():s(o.status+""+o.statusText)}:Vo}async function Vo(){s("No HTTP fetch method available.")}const Xo=t=>null!=t&&t==t,Jo=t=>!(Number.isNaN(+t)||t instanceof Date),Zo={boolean:Ft,integer:S,number:S,date:$t,string:Tt,unknown:f},Qo=[t=>"true"===t||"false"===t||!0===t||!1===t,t=>Jo(t)&&Number.isInteger(+t),Jo,t=>!Number.isNaN(Date.parse(t))],Ko=["boolean","integer","number","date"];function ta(t,e){if(!t||!t.length)return"unknown";const n=t.length,r=Qo.length,i=Qo.map(((t,e)=>e+1));for(let o,a,s=0,u=0;s0===t?e:t),0)-1]}function ea(t,e){return e.reduce(((e,n)=>(e[n]=ta(t,n),e)),{})}function na(t){const e=function(e,n){const r={delimiter:t};return ra(e,n?ot(n,r):r)};return e.responseType="text",e}function ra(t,e){return e.header&&(t=e.header.map(Ct).join(e.delimiter)+"\n"+t),Ht(e.delimiter).parse(t+"")}function ia(t,e){const n=e&&e.property?l(e.property):f;return!A(t)||(r=t,"function"==typeof Buffer&&J(Buffer.isBuffer)&&Buffer.isBuffer(r))?n(JSON.parse(t)):function(t,e){!k(t)&&yt(t)&&(t=[...t]);return e&&e.copy?JSON.parse(JSON.stringify(t)):t}(n(t),e);var r}ra.responseType="text",ia.responseType="json";const oa={interior:(t,e)=>t!==e,exterior:(t,e)=>t===e};function aa(t,e){let n,r,i,o;return t=ia(t,e),e&&e.feature?(n=Gt,i=e.feature):e&&e.mesh?(n=Zt,i=e.mesh,o=oa[e.filter]):s("Missing TopoJSON feature or mesh parameter."),r=(r=t.objects[i])?n(t,r,o):s("Invalid TopoJSON object: "+i),r&&r.features||[r]}aa.responseType="json";const sa={dsv:ra,csv:na(","),tsv:na("\t"),json:ia,topojson:aa};function ua(t,e){return arguments.length>1?(sa[t]=e,this):lt(sa,t)?sa[t]:null}function la(t){const e=ua(t);return e&&e.responseType||"text"}function ca(t,e,n,r){const i=ua((e=e||{}).type||"json");return i||s("Unknown data format type: "+e.type),t=i(t,e),e.parse&&function(t,e,n,r){if(!t.length)return;const i=No();n=n||i.timeParse,r=r||i.utcParse;let o,a,s,u,l,c,f=t.columns||Object.keys(t[0]);"auto"===e&&(e=ea(t,f));f=Object.keys(e);const h=f.map((t=>{const i=e[t];let o,a;if(i&&(i.startsWith("date:")||i.startsWith("utc:"))){o=i.split(/:(.+)?/,2),a=o[1],("'"===a[0]&&"'"===a[a.length-1]||'"'===a[0]&&'"'===a[a.length-1])&&(a=a.slice(1,-1));return("utc"===o[0]?r:n)(a)}if(!Zo[i])throw Error("Illegal format pattern: "+t+":"+i);return Zo[i]}));for(s=0,l=t.length,c=f.length;s({options:n||{},sanitize:Wo,load:Io,fileAccess:!!e,file:Ho(e),http:Go(t)})}("undefined"!=typeof fetch&&fetch,null);function ha(t){const e=t||f,n=[],r={};return n.add=t=>{const i=e(t);return r[i]||(r[i]=1,n.push(t)),n},n.remove=t=>{const i=e(t);if(r[i]){r[i]=0;const e=n.indexOf(t);e>=0&&n.splice(e,1)}return n},n}async function da(t,e){try{await e(t)}catch(e){t.error(e)}}const pa=Symbol("vega_id");let ga=1;function ma(t){return!(!t||!ya(t))}function ya(t){return t[pa]}function va(t,e){return t[pa]=e,t}function _a(t){const e=t===Object(t)?t:{data:t};return ya(e)?e:va(e,ga++)}function xa(t){return ba(t,_a({}))}function ba(t,e){for(const n in t)e[n]=t[n];return e}function wa(t,e){return va(e,ya(t))}function ka(t,e){return t?e?(n,r)=>t(n,r)||ya(e(n))-ya(e(r)):(e,n)=>t(e,n)||ya(e)-ya(n):null}function Aa(t){return t&&t.constructor===Ma}function Ma(){const t=[],e=[],n=[],r=[],i=[];let o=null,a=!1;return{constructor:Ma,insert(e){const n=V(e),r=n.length;for(let e=0;e{p(t)&&(l[ya(t)]=-1)}));for(f=0,h=t.length;f0&&(y(g,p,d.value),s.modifies(p));for(f=0,h=i.length;f{p(t)&&l[ya(t)]>0&&y(t,d.field,d.value)})),s.modifies(d.field);if(a)s.mod=e.length||r.length?u.filter((t=>l[ya(t)]>0)):u.slice();else for(m in c)s.mod.push(c[m]);return(o||null==o&&(e.length||r.length))&&s.clean(!0),s}}}const Ea="_:mod:_";function Da(){Object.defineProperty(this,Ea,{writable:!0,value:{}})}Da.prototype={set(t,e,n,r){const i=this,o=i[t],a=i[Ea];return null!=e&&e>=0?(o[e]!==n||r)&&(o[e]=n,a[e+":"+t]=-1,a[t]=-1):(o!==n||r)&&(i[t]=n,a[t]=k(n)?1+n.length:-1),i},modified(t,e){const n=this[Ea];if(!arguments.length){for(const t in n)if(n[t])return!0;return!1}if(k(t)){for(let e=0;e=0?e+1{a instanceof Sa?(a!==this&&(e&&a.targets().add(this),o.push(a)),i.push({op:a,name:t,index:n})):r.set(t,n,a)};for(a in t)if(u=t[a],"pulse"===a)V(u).forEach((t=>{t instanceof Sa?t!==this&&(t.targets().add(this),o.push(t)):s("Pulse parameters must be operator instances.")})),this.source=u;else if(k(u))for(r.set(a,-1,Array(l=u.length)),c=0;c{const n=Date.now();return n-e>t?(e=n,1):0}))},debounce(t){const e=za();return this.targets().add(za(null,null,it(t,(t=>{const n=t.dataflow;e.receive(t),n&&n.run&&n.run()})))),e},between(t,e){let n=!1;return t.targets().add(za(null,null,(()=>n=!0))),e.targets().add(za(null,null,(()=>n=!1))),this.filter((()=>n))},detach(){this._filter=p,this._targets=null}};const Na={skip:!0};function Oa(t,e,n,r,i,o){const a=ot({},o,Na);let s,u;J(n)||(n=rt(n)),void 0===r?s=e=>t.touch(n(e)):J(r)?(u=new Sa(null,r,i,!1),s=e=>{u.evaluate(e);const r=n(e),i=u.value;Aa(i)?t.pulse(r,i,o):t.update(r,i,a)}):s=e=>t.update(n(e),r,a),e.apply(s)}function Ra(t,e,n,r,i,o){if(void 0===r)e.targets().add(n);else{const a=o||{},s=new Sa(null,function(t,e){return e=J(e)?e:rt(e),t?function(n,r){const i=e(n,r);return t.skip()||(t.skip(i!==this.value).value=i),i}:e}(n,r),i,!1);s.modified(a.force),s.rank=e.rank,e.targets().add(s),n&&(s.skip(!0),s.value=n.value,s.targets().add(n),t.connect(n,[s]))}}const Ua={};function La(t,e,n){this.dataflow=t,this.stamp=null==e?-1:e,this.add=[],this.rem=[],this.mod=[],this.fields=null,this.encode=n||null}function qa(t,e){const n=[];return Nt(t,e,(t=>n.push(t))),n}function Pa(t,e){const n={};return t.visit(e,(t=>{n[ya(t)]=1})),t=>n[ya(t)]?null:t}function ja(t,e){return t?(n,r)=>t(n,r)&&e(n,r):e}function Ia(t,e,n,r){const i=this;let o=0;this.dataflow=t,this.stamp=e,this.fields=null,this.encode=r||null,this.pulses=n;for(const t of n)if(t.stamp===e){if(t.fields){const e=i.fields||(i.fields={});for(const n in t.fields)e[n]=1}t.changed(i.ADD)&&(o|=i.ADD),t.changed(i.REM)&&(o|=i.REM),t.changed(i.MOD)&&(o|=i.MOD)}this.changes=o}function Wa(t){return t.error("Dataflow already running. Use runAsync() to chain invocations."),t}La.prototype={StopPropagation:Ua,ADD:1,REM:2,MOD:4,ADD_REM:3,ADD_MOD:5,ALL:7,REFLOW:8,SOURCE:16,NO_SOURCE:32,NO_FIELDS:64,fork(t){return new La(this.dataflow).init(this,t)},clone(){const t=this.fork(7);return t.add=t.add.slice(),t.rem=t.rem.slice(),t.mod=t.mod.slice(),t.source&&(t.source=t.source.slice()),t.materialize(23)},addAll(){let t=this;return!t.source||t.add===t.rem||!t.rem.length&&t.source.length===t.add.length||(t=new La(this.dataflow).init(this),t.add=t.source,t.rem=[]),t},init(t,e){const n=this;return n.stamp=t.stamp,n.encode=t.encode,!t.fields||64&e||(n.fields=t.fields),1&e?(n.addF=t.addF,n.add=t.add):(n.addF=null,n.add=[]),2&e?(n.remF=t.remF,n.rem=t.rem):(n.remF=null,n.rem=[]),4&e?(n.modF=t.modF,n.mod=t.mod):(n.modF=null,n.mod=[]),32&e?(n.srcF=null,n.source=null):(n.srcF=t.srcF,n.source=t.source,t.cleans&&(n.cleans=t.cleans)),n},runAfter(t){this.dataflow.runAfter(t)},changed(t){const e=t||7;return 1&e&&this.add.length||2&e&&this.rem.length||4&e&&this.mod.length},reflow(t){if(t)return this.fork(7).reflow();const e=this.add.length,n=this.source&&this.source.length;return n&&n!==e&&(this.mod=this.source,e&&this.filter(4,Pa(this,1))),this},clean(t){return arguments.length?(this.cleans=!!t,this):this.cleans},modifies(t){const e=this.fields||(this.fields={});return k(t)?t.forEach((t=>e[t]=!0)):e[t]=!0,this},modified(t,e){const n=this.fields;return!(!e&&!this.mod.length||!n)&&(arguments.length?k(t)?t.some((t=>n[t])):n[t]:!!n)},filter(t,e){const n=this;return 1&t&&(n.addF=ja(n.addF,e)),2&t&&(n.remF=ja(n.remF,e)),4&t&&(n.modF=ja(n.modF,e)),16&t&&(n.srcF=ja(n.srcF,e)),n},materialize(t){const e=this;return 1&(t=t||7)&&e.addF&&(e.add=qa(e.add,e.addF),e.addF=null),2&t&&e.remF&&(e.rem=qa(e.rem,e.remF),e.remF=null),4&t&&e.modF&&(e.mod=qa(e.mod,e.modF),e.modF=null),16&t&&e.srcF&&(e.source=e.source.filter(e.srcF),e.srcF=null),e},visit(t,e){const n=this,r=e;if(16&t)return Nt(n.source,n.srcF,r),n;1&t&&Nt(n.add,n.addF,r),2&t&&Nt(n.rem,n.remF,r),4&t&&Nt(n.mod,n.modF,r);const i=n.source;if(8&t&&i){const t=n.add.length+n.mod.length;t===i.length||Nt(i,t?Pa(n,5):n.srcF,r)}return n}},dt(Ia,La,{fork(t){const e=new La(this.dataflow).init(this,t&this.NO_FIELDS);return void 0!==t&&(t&e.ADD&&this.visit(e.ADD,(t=>e.add.push(t))),t&e.REM&&this.visit(e.REM,(t=>e.rem.push(t))),t&e.MOD&&this.visit(e.MOD,(t=>e.mod.push(t)))),e},changed(t){return this.changes&t},modified(t){const e=this,n=e.fields;return n&&e.changes&e.MOD?k(t)?t.some((t=>n[t])):n[t]:0},filter(){s("MultiPulse does not support filtering.")},materialize(){s("MultiPulse does not support materialization.")},visit(t,e){const n=this,r=n.pulses,i=r.length;let o=0;if(t&n.SOURCE)for(;oe=[],size:()=>e.length,peek:()=>e[0],push:n=>(e.push(n),Ga(e,0,e.length-1,t)),pop:()=>{const n=e.pop();let r;return e.length?(r=e[0],e[0]=n,function(t,e,n){const r=e,i=t.length,o=t[e];let a,s=1+(e<<1);for(;s=0&&(s=a),t[e]=t[s],s=1+((e=s)<<1);t[e]=o,Ga(t,r,e,n)}(e,0,t)):r=n,r}}}function Ga(t,e,n,r){let i,o;const a=t[n];for(;n>e&&(o=n-1>>1,i=t[o],r(a,i)<0);)t[n]=i,n=o;return t[n]=a}function Va(){this.logger(w()),this.logLevel(v),this._clock=0,this._rank=0,this._locale=Uo();try{this._loader=fa()}catch(t){}this._touched=ha(c),this._input={},this._pulse=null,this._heap=Ya(((t,e)=>t.qrank-e.qrank)),this._postrun=[]}function Xa(t){return function(){return this._log[t].apply(this,arguments)}}function Ja(t,e){Sa.call(this,t,null,e)}Va.prototype={stamp(){return this._clock},loader(t){return arguments.length?(this._loader=t,this):this._loader},locale(t){return arguments.length?(this._locale=t,this):this._locale},logger(t){return arguments.length?(this._log=t,this):this._log},error:Xa("error"),warn:Xa("warn"),info:Xa("info"),debug:Xa("debug"),logLevel:Xa("level"),cleanThreshold:1e4,add:function(t,e,n,r){let i,o=1;return t instanceof Sa?i=t:t&&t.prototype instanceof Sa?i=new t:J(t)?i=new Sa(null,t):(o=0,i=new Sa(t,e)),this.rank(i),o&&(r=n,n=e),n&&this.connect(i,i.parameters(n,r)),this.touch(i),i},connect:function(t,e){const n=t.rank,r=e.length;for(let i=0;i=0;)e.push(n=r[i]),n===t&&s("Cycle detected in dataflow graph.")},pulse:function(t,e,n){this.touch(t,n||Ha);const r=new La(this,this._clock+(this._pulse?0:1)),i=t.pulse&&t.pulse.source||[];return r.target=t,this._input[t.id]=e.pulse(r,i),this},touch:function(t,e){const n=e||Ha;return this._pulse?this._enqueue(t):this._touched.add(t),n.skip&&t.skip(!0),this},update:function(t,e,n){const r=n||Ha;return(t.set(e)||r.force)&&this.touch(t,r),this},changeset:Ma,ingest:function(t,e,n){return e=this.parse(e,n),this.pulse(t,this.changeset().insert(e))},parse:function(t,e){const n=this.locale();return ca(t,e,n.timeParse,n.utcParse)},preload:async function(t,e,n){const r=this,i=r._pending||function(t){let e;const n=new Promise((t=>e=t));return n.requests=0,n.done=()=>{0==--n.requests&&(t._pending=null,e(t))},t._pending=n}(r);i.requests+=1;const o=await r.request(e,n);return r.pulse(t,r.changeset().remove(p).insert(o.data||[])),i.done(),o},request:async function(t,e){const n=this;let r,i=0;try{r=await n.loader().load(t,{context:"dataflow",response:la(e&&e.type)});try{r=n.parse(r,e)}catch(e){i=-2,n.warn("Data ingestion failed",t,e)}}catch(e){i=-1,n.warn("Loading failed",t,e)}return{data:r,status:i}},events:function(t,e,n,r){const i=this,o=za(n,r),a=function(t){t.dataflow=i;try{o.receive(t)}catch(t){i.error(t)}finally{i.run()}};let s;s="string"==typeof t&&"undefined"!=typeof document?document.querySelectorAll(t):V(t);const u=s.length;for(let t=0;tr._enqueue(t,!0))),r._touched=ha(c);let a,s,u,l=0;try{for(;r._heap.size()>0;)a=r._heap.pop(),a.rank===a.qrank?(s=a.run(r._getPulse(a,t)),s.then?s=await s:s.async&&(i.push(s.async),s=Ua),s!==Ua&&a._targets&&a._targets.forEach((t=>r._enqueue(t))),++l):r._enqueue(a,!0)}catch(t){r._heap.clear(),u=t}if(r._input={},r._pulse=null,r.debug(`Pulse ${o}: ${l} operators`),u&&(r._postrun=[],r.error(u)),r._postrun.length){const t=r._postrun.sort(((t,e)=>e.priority-t.priority));r._postrun=[];for(let e=0;er.runAsync(null,(()=>{t.forEach((t=>{try{t(r)}catch(t){r.error(t)}}))})))),r},run:function(t,e,n){return this._pulse?Wa(this):(this.evaluate(t,e,n),this)},runAsync:async function(t,e,n){for(;this._running;)await this._running;const r=()=>this._running=null;return(this._running=this.evaluate(t,e,n)).then(r,r),this._running},runAfter:function(t,e,n){if(this._pulse||e)this._postrun.push({priority:n||0,callback:t});else try{t(this)}catch(t){this.error(t)}},_enqueue:function(t,e){const n=t.stampt.pulse)),e):this._input[t.id]||function(t,e){if(e&&e.stamp===t.stamp)return e;t=t.fork(),e&&e!==Ua&&(t.source=e.source);return t}(this._pulse,n&&n.pulse)}},dt(Ja,Sa,{run(t){if(t.stampthis.pulse=t)):e!==t.StopPropagation&&(this.pulse=e),e},evaluate(t){const e=this.marshall(t.stamp),n=this.transform(e,t);return e.clear(),n},transform(){}});const Za={};function Qa(t){const e=Ka(t);return e&&e.Definition||null}function Ka(t){return t=t&&t.toLowerCase(),lt(Za,t)?Za[t]:null}function*ts(t,e){if(null==e)for(let e of t)null!=e&&""!==e&&(e=+e)>=e&&(yield e);else{let n=-1;for(let r of t)r=e(r,++n,t),null!=r&&""!==r&&(r=+r)>=r&&(yield r)}}function es(t,e,n){const r=Float64Array.from(ts(t,n));return r.sort(Kt),e.map((t=>De(r,t)))}function ns(t,e){return es(t,[.25,.5,.75],e)}function rs(t,e){const n=t.length,r=function(t,e){const n=function(t,e){let n,r=0,i=0,o=0;if(void 0===e)for(let e of t)null!=e&&(e=+e)>=e&&(n=e-i,i+=n/++r,o+=n*(e-i));else{let a=-1;for(let s of t)null!=(s=e(s,++a,t))&&(s=+s)>=s&&(n=s-i,i+=n/++r,o+=n*(s-i))}if(r>1)return o/(r-1)}(t,e);return n?Math.sqrt(n):n}(t,e),i=ns(t,e),o=(i[2]-i[0])/1.34;return 1.06*(Math.min(r,o)||r||Math.abs(i[0])||1)*Math.pow(n,-.2)}function is(t){const e=t.maxbins||20,n=t.base||10,r=Math.log(n),i=t.divide||[5,2];let o,a,s,u,l,c,f=t.extent[0],h=t.extent[1];const d=t.span||h-f||Math.abs(f)||1;if(t.step)o=t.step;else if(t.steps){for(u=d/e,l=0,c=t.steps.length;le;)o*=n;for(l=0,c=i.length;l=s&&d/u<=e&&(o=u)}u=Math.log(o);const p=u>=0?0:1+~~(-u/r),g=Math.pow(n,-p-1);return(t.nice||void 0===t.nice)&&(u=Math.floor(f/o+g)*o,f=ft);const i=t.length,o=new Float64Array(i);let a,s=0,u=1,l=r(t[0]),c=l,f=l+e;for(;u=f){for(c=(l+c)/2;s>1);ia;)t[i--]=t[o]}o=a,a=r}return t}(o,e+e/4):o}t.random=Math.random;const ss=Math.sqrt(2*Math.PI),us=Math.SQRT2;let ls=NaN;function cs(e,n){e=e||0,n=null==n?1:n;let r,i,o=0,a=0;if(ls==ls)o=ls,ls=NaN;else{do{o=2*t.random()-1,a=2*t.random()-1,r=o*o+a*a}while(0===r||r>1);i=Math.sqrt(-2*Math.log(r)/r),o*=i,ls=a*i}return e+o*n}function fs(t,e,n){const r=(t-(e||0))/(n=null==n?1:n);return Math.exp(-.5*r*r)/(n*ss)}function hs(t,e,n){const r=(t-(e=e||0))/(n=null==n?1:n),i=Math.abs(r);let o;if(i>37)o=0;else{const t=Math.exp(-i*i/2);let e;i<7.07106781186547?(e=.0352624965998911*i+.700383064443688,e=e*i+6.37396220353165,e=e*i+33.912866078383,e=e*i+112.079291497871,e=e*i+221.213596169931,e=e*i+220.206867912376,o=t*e,e=.0883883476483184*i+1.75566716318264,e=e*i+16.064177579207,e=e*i+86.7807322029461,e=e*i+296.564248779674,e=e*i+637.333633378831,e=e*i+793.826512519948,e=e*i+440.413735824752,o/=e):(e=i+.65,e=i+4/e,e=i+3/e,e=i+2/e,e=i+1/e,o=t/e/2.506628274631)}return r>0?1-o:o}function ds(t,e,n){return t<0||t>1?NaN:(e||0)+(null==n?1:n)*us*function(t){let e,n=-Math.log((1-t)*(1+t));n<6.25?(n-=3.125,e=-364441206401782e-35,e=e*n-16850591381820166e-35,e=128584807152564e-32+e*n,e=11157877678025181e-33+e*n,e=e*n-1333171662854621e-31,e=20972767875968562e-33+e*n,e=6637638134358324e-30+e*n,e=e*n-4054566272975207e-29,e=e*n-8151934197605472e-29,e=26335093153082323e-28+e*n,e=e*n-12975133253453532e-27,e=e*n-5415412054294628e-26,e=1.0512122733215323e-9+e*n,e=e*n-4.112633980346984e-9,e=e*n-2.9070369957882005e-8,e=4.2347877827932404e-7+e*n,e=e*n-13654692000834679e-22,e=e*n-13882523362786469e-21,e=.00018673420803405714+e*n,e=e*n-.000740702534166267,e=e*n-.006033670871430149,e=.24015818242558962+e*n,e=1.6536545626831027+e*n):n<16?(n=Math.sqrt(n)-3.25,e=2.2137376921775787e-9,e=9.075656193888539e-8+e*n,e=e*n-2.7517406297064545e-7,e=1.8239629214389228e-8+e*n,e=15027403968909828e-22+e*n,e=e*n-4013867526981546e-21,e=29234449089955446e-22+e*n,e=12475304481671779e-21+e*n,e=e*n-47318229009055734e-21,e=6828485145957318e-20+e*n,e=24031110387097894e-21+e*n,e=e*n-.0003550375203628475,e=.0009532893797373805+e*n,e=e*n-.0016882755560235047,e=.002491442096107851+e*n,e=e*n-.003751208507569241,e=.005370914553590064+e*n,e=1.0052589676941592+e*n,e=3.0838856104922208+e*n):Number.isFinite(n)?(n=Math.sqrt(n)-5,e=-27109920616438573e-27,e=e*n-2.555641816996525e-10,e=1.5076572693500548e-9+e*n,e=e*n-3.789465440126737e-9,e=7.61570120807834e-9+e*n,e=e*n-1.496002662714924e-8,e=2.914795345090108e-8+e*n,e=e*n-6.771199775845234e-8,e=2.2900482228026655e-7+e*n,e=e*n-9.9298272942317e-7,e=4526062597223154e-21+e*n,e=e*n-1968177810553167e-20,e=7599527703001776e-20+e*n,e=e*n-.00021503011930044477,e=e*n-.00013871931833623122,e=1.0103004648645344+e*n,e=4.849906401408584+e*n):e=1/0;return e*t}(2*t-1)}function ps(t,e){let n,r;const i={mean(t){return arguments.length?(n=t||0,i):n},stdev(t){return arguments.length?(r=null==t?1:t,i):r},sample:()=>cs(n,r),pdf:t=>fs(t,n,r),cdf:t=>hs(t,n,r),icdf:t=>ds(t,n,r)};return i.mean(t).stdev(e)}function gs(e,n){const r=ps();let i=0;const o={data(t){return arguments.length?(e=t,i=t?t.length:0,o.bandwidth(n)):e},bandwidth(t){return arguments.length?(!(n=t)&&e&&(n=rs(e)),o):n},sample:()=>e[~~(t.random()*i)]+n*r.sample(),pdf(t){let o=0,a=0;for(;ams(n,r),pdf:t=>ys(t,n,r),cdf:t=>vs(t,n,r),icdf:t=>_s(t,n,r)};return i.mean(t).stdev(e)}function bs(e,n){let r,i=0;const o={weights(t){return arguments.length?(r=function(t){const e=[];let n,r=0;for(n=0;n=e&&t<=n?1/(n-e):0}function As(t,e,n){return null==n&&(n=null==e?1:e,e=0),tn?1:(t-e)/(n-e)}function Ms(t,e,n){return null==n&&(n=null==e?1:e,e=0),t>=0&&t<=1?e+t*(n-e):NaN}function Es(t,e){let n,r;const i={min(t){return arguments.length?(n=t||0,i):n},max(t){return arguments.length?(r=null==t?1:t,i):r},sample:()=>ws(n,r),pdf:t=>ks(t,n,r),cdf:t=>As(t,n,r),icdf:t=>Ms(t,n,r)};return null==e&&(e=null==t?1:t,t=0),i.min(t).max(e)}function Ds(t,e,n){let r=0,i=0;for(const o of t){const t=n(o);null==e(o)||null==t||isNaN(t)||(r+=(t-r)/++i)}return{coef:[r],predict:()=>r,rSquared:0}}function Cs(t,e,n,r){const i=r-t*t,o=Math.abs(i)<1e-24?0:(n-t*e)/i;return[e-o*t,o]}function Fs(t,e,n,r){t=t.filter((t=>{let r=e(t),i=n(t);return null!=r&&(r=+r)>=r&&null!=i&&(i=+i)>=i})),r&&t.sort(((t,n)=>e(t)-e(n)));const i=t.length,o=new Float64Array(i),a=new Float64Array(i);let s,u,l,c=0,f=0,h=0;for(l of t)o[c]=s=+e(l),a[c]=u=+n(l),++c,f+=(s-f)/c,h+=(u-h)/c;for(c=0;c=i&&null!=o&&(o=+o)>=o&&r(i,o,++a)}function $s(t,e,n,r,i){let o=0,a=0;return Ss(t,e,n,((t,e)=>{const n=e-i(t),s=e-r;o+=n*n,a+=s*s})),1-o/a}function Ts(t,e,n){let r=0,i=0,o=0,a=0,s=0;Ss(t,e,n,((t,e)=>{++s,r+=(t-r)/s,i+=(e-i)/s,o+=(t*e-o)/s,a+=(t*t-a)/s}));const u=Cs(r,i,o,a),l=t=>u[0]+u[1]*t;return{coef:u,predict:l,rSquared:$s(t,e,n,i,l)}}function Bs(t,e,n){let r=0,i=0,o=0,a=0,s=0;Ss(t,e,n,((t,e)=>{++s,t=Math.log(t),r+=(t-r)/s,i+=(e-i)/s,o+=(t*e-o)/s,a+=(t*t-a)/s}));const u=Cs(r,i,o,a),l=t=>u[0]+u[1]*Math.log(t);return{coef:u,predict:l,rSquared:$s(t,e,n,i,l)}}function zs(t,e,n){const[r,i,o,a]=Fs(t,e,n);let s,u,l,c=0,f=0,h=0,d=0,p=0;Ss(t,e,n,((t,e)=>{s=r[p++],u=Math.log(e),l=s*e,c+=(e*u-c)/p,f+=(l-f)/p,h+=(l*u-h)/p,d+=(s*l-d)/p}));const[g,m]=Cs(f/a,c/a,h/a,d/a),y=t=>Math.exp(g+m*(t-o));return{coef:[Math.exp(g-m*o),m],predict:y,rSquared:$s(t,e,n,a,y)}}function Ns(t,e,n){let r=0,i=0,o=0,a=0,s=0,u=0;Ss(t,e,n,((t,e)=>{const n=Math.log(t),l=Math.log(e);++u,r+=(n-r)/u,i+=(l-i)/u,o+=(n*l-o)/u,a+=(n*n-a)/u,s+=(e-s)/u}));const l=Cs(r,i,o,a),c=t=>l[0]*Math.pow(t,l[1]);return l[0]=Math.exp(l[0]),{coef:l,predict:c,rSquared:$s(t,e,n,s,c)}}function Os(t,e,n){const[r,i,o,a]=Fs(t,e,n),s=r.length;let u,l,c,f,h=0,d=0,p=0,g=0,m=0;for(u=0;u_*(t-=o)*t+x*t+b+a;return{coef:[b-x*o+_*o*o+a,x-2*_*o,_],predict:w,rSquared:$s(t,e,n,a,w)}}function Rs(t,e,n,r){if(0===r)return Ds(t,e,n);if(1===r)return Ts(t,e,n);if(2===r)return Os(t,e,n);const[i,o,a,s]=Fs(t,e,n),u=i.length,l=[],c=[],f=r+1;let h,d,p,g,m;for(h=0;hMath.abs(t[r][a])&&(a=i);for(o=r;o=r;o--)t[o][i]-=t[o][r]*t[r][i]/t[r][r]}for(i=e-1;i>=0;--i){for(s=0,o=i+1;o{t-=a;let e=s+y[0]+y[1]*t+y[2]*t*t;for(h=3;h=0;--o)for(s=e[o],u=1,i[o]+=s,a=1;a<=o;++a)u*=(o+1-a)/a,i[o-a]+=s*Math.pow(n,a)*u;return i[0]+=r,i}function Ls(t,e,n,r){const[i,o,a,s]=Fs(t,e,n,!0),u=i.length,l=Math.max(2,~~(r*u)),c=new Float64Array(u),f=new Float64Array(u),h=new Float64Array(u).fill(1);for(let t=-1;++t<=2;){const e=[0,l-1];for(let t=0;ti[a]-n?r:a;let u=0,l=0,d=0,p=0,g=0;const m=1/Math.abs(i[s]-n||1);for(let t=r;t<=a;++t){const e=i[t],r=o[t],a=qs(Math.abs(n-e)*m)*h[t],s=e*a;u+=a,l+=s,d+=r*a,p+=r*s,g+=e*s}const[y,v]=Cs(l/u,d/u,p/u,g/u);c[t]=y+v*n,f[t]=Math.abs(o[t]-c[t]),Ps(i,t+1,e)}if(2===t)break;const n=Ce(f);if(Math.abs(n)<1e-12)break;for(let t,e,r=0;r=1?1e-12:(e=1-t*t)*e}return function(t,e,n,r){const i=t.length,o=[];let a,s=0,u=0,l=[];for(;s=t.length))for(;e>i&&t[o]-r<=r-t[i];)n[0]=++i,n[1]=o,++o}const js=.5*Math.PI/180;function Is(t,e,n,r){n=n||25,r=Math.max(n,r||200);const i=e=>[e,t(e)],o=e[0],a=e[1],s=a-o,u=s/r,l=[i(o)],c=[];if(n===r){for(let t=1;t0;)c.push(i(o+t/n*s));let f=l[0],h=c[c.length-1];const d=1/s,p=function(t,e){let n=t,r=t;const i=e.length;for(let t=0;tr&&(r=i)}return 1/(r-n)}(f[1],c);for(;h;){const t=i((f[0]+h[0])/2);t[0]-f[0]>=u&&Ws(f,t,h,d,p)>js?c.push(t):(f=h,l.push(h),c.pop()),h=c[c.length-1]}return l}function Ws(t,e,n,r,i){const o=Math.atan2(i*(n[1]-t[1]),r*(n[0]-t[0])),a=Math.atan2(i*(e[1]-t[1]),r*(e[0]-t[0]));return Math.abs(o-a)}function Hs(t){return t&&t.length?1===t.length?t[0]:(e=t,t=>{const n=e.length;let r=1,i=String(e[0](t));for(;r{},Vs={init:Gs,add:Gs,rem:Gs,idx:0},Xs={values:{init:t=>t.cell.store=!0,value:t=>t.cell.data.values(),idx:-1},count:{value:t=>t.cell.num},__count__:{value:t=>t.missing+t.valid},missing:{value:t=>t.missing},valid:{value:t=>t.valid},sum:{init:t=>t.sum=0,value:t=>t.valid?t.sum:void 0,add:(t,e)=>t.sum+=+e,rem:(t,e)=>t.sum-=e},product:{init:t=>t.product=1,value:t=>t.valid?t.product:void 0,add:(t,e)=>t.product*=e,rem:(t,e)=>t.product/=e},mean:{init:t=>t.mean=0,value:t=>t.valid?t.mean:void 0,add:(t,e)=>(t.mean_d=e-t.mean,t.mean+=t.mean_d/t.valid),rem:(t,e)=>(t.mean_d=e-t.mean,t.mean-=t.valid?t.mean_d/t.valid:t.mean)},average:{value:t=>t.valid?t.mean:void 0,req:["mean"],idx:1},variance:{init:t=>t.dev=0,value:t=>t.valid>1?t.dev/(t.valid-1):void 0,add:(t,e)=>t.dev+=t.mean_d*(e-t.mean),rem:(t,e)=>t.dev-=t.mean_d*(e-t.mean),req:["mean"],idx:1},variancep:{value:t=>t.valid>1?t.dev/t.valid:void 0,req:["variance"],idx:2},stdev:{value:t=>t.valid>1?Math.sqrt(t.dev/(t.valid-1)):void 0,req:["variance"],idx:2},stdevp:{value:t=>t.valid>1?Math.sqrt(t.dev/t.valid):void 0,req:["variance"],idx:2},stderr:{value:t=>t.valid>1?Math.sqrt(t.dev/(t.valid*(t.valid-1))):void 0,req:["variance"],idx:2},distinct:{value:t=>t.cell.data.distinct(t.get),req:["values"],idx:3},ci0:{value:t=>t.cell.data.ci0(t.get),req:["values"],idx:3},ci1:{value:t=>t.cell.data.ci1(t.get),req:["values"],idx:3},median:{value:t=>t.cell.data.q2(t.get),req:["values"],idx:3},q1:{value:t=>t.cell.data.q1(t.get),req:["values"],idx:3},q3:{value:t=>t.cell.data.q3(t.get),req:["values"],idx:3},min:{init:t=>t.min=void 0,value:t=>t.min=Number.isNaN(t.min)?t.cell.data.min(t.get):t.min,add:(t,e)=>{(e{e<=t.min&&(t.min=NaN)},req:["values"],idx:4},max:{init:t=>t.max=void 0,value:t=>t.max=Number.isNaN(t.max)?t.cell.data.max(t.get):t.max,add:(t,e)=>{(e>t.max||void 0===t.max)&&(t.max=e)},rem:(t,e)=>{e>=t.max&&(t.max=NaN)},req:["values"],idx:4},argmin:{init:t=>t.argmin=void 0,value:t=>t.argmin||t.cell.data.argmin(t.get),add:(t,e,n)=>{e{e<=t.min&&(t.argmin=void 0)},req:["min","values"],idx:3},argmax:{init:t=>t.argmax=void 0,value:t=>t.argmax||t.cell.data.argmax(t.get),add:(t,e,n)=>{e>t.max&&(t.argmax=n)},rem:(t,e)=>{e>=t.max&&(t.argmax=void 0)},req:["max","values"],idx:3},exponential:{init:(t,e)=>{t.exp=0,t.exp_r=e},value:t=>t.valid?t.exp*(1-t.exp_r)/(1-t.exp_r**t.valid):void 0,add:(t,e)=>t.exp=t.exp_r*t.exp+e,rem:(t,e)=>t.exp=(t.exp-e/t.exp_r**(t.valid-1))/t.exp_r},exponentialb:{value:t=>t.valid?t.exp*(1-t.exp_r):void 0,req:["exponential"],idx:1}},Js=Object.keys(Xs).filter((t=>"__count__"!==t));function Zs(t,e,n){return Xs[t](n,e)}function Qs(t,e){return t.idx-e.idx}function Ks(){this.valid=0,this.missing=0,this._ops.forEach((t=>null==t.aggregate_param?t.init(this):t.init(this,t.aggregate_param)))}function tu(t,e){null!=t&&""!==t?t==t&&(++this.valid,this._ops.forEach((n=>n.add(this,t,e)))):++this.missing}function eu(t,e){null!=t&&""!==t?t==t&&(--this.valid,this._ops.forEach((n=>n.rem(this,t,e)))):--this.missing}function nu(t){return this._out.forEach((e=>t[e.out]=e.value(this))),t}function ru(t,e){const n=e||f,r=function(t){const e={};t.forEach((t=>e[t.name]=t));const n=t=>{t.req&&t.req.forEach((t=>{e[t]||n(e[t]=Xs[t]())}))};return t.forEach(n),Object.values(e).sort(Qs)}(t),i=t.slice().sort(Qs);function o(t){this._ops=r,this._out=i,this.cell=t,this.init()}return o.prototype.init=Ks,o.prototype.add=tu,o.prototype.rem=eu,o.prototype.set=nu,o.prototype.get=n,o.fields=t.map((t=>t.out)),o}function iu(t){this._key=t?l(t):ya,this.reset()}[...Js,"__count__"].forEach((t=>{Xs[t]=function(t,e){return(n,r)=>ot({name:t,aggregate_param:r,out:n||t},Vs,e)}(t,Xs[t])}));const ou=iu.prototype;function au(t){Ja.call(this,null,t),this._adds=[],this._mods=[],this._alen=0,this._mlen=0,this._drop=!0,this._cross=!1,this._dims=[],this._dnames=[],this._measures=[],this._countOnly=!1,this._counts=null,this._prev=null,this._inputs=null,this._outputs=null}ou.reset=function(){this._add=[],this._rem=[],this._ext=null,this._get=null,this._q=null},ou.add=function(t){this._add.push(t)},ou.rem=function(t){this._rem.push(t)},ou.values=function(){if(this._get=null,0===this._rem.length)return this._add;const t=this._add,e=this._rem,n=this._key,r=t.length,i=e.length,o=Array(r-i),a={};let s,u,l;for(s=0;s=0;)r=t(e[i])+"",lt(n,r)||(n[r]=1,++o);return o},ou.extent=function(t){if(this._get!==t||!this._ext){const e=this.values(),n=st(e,t);this._ext=[e[n[0]],e[n[1]]],this._get=t}return this._ext},ou.argmin=function(t){return this.extent(t)[0]||{}},ou.argmax=function(t){return this.extent(t)[1]||{}},ou.min=function(t){const e=this.extent(t)[0];return null!=e?t(e):void 0},ou.max=function(t){const e=this.extent(t)[1];return null!=e?t(e):void 0},ou.quartile=function(t){return this._get===t&&this._q||(this._q=ns(this.values(),t),this._get=t),this._q},ou.q1=function(t){return this.quartile(t)[0]},ou.q2=function(t){return this.quartile(t)[1]},ou.q3=function(t){return this.quartile(t)[2]},ou.ci=function(t){return this._get===t&&this._ci||(this._ci=os(this.values(),1e3,.05,t),this._get=t),this._ci},ou.ci0=function(t){return this.ci(t)[0]},ou.ci1=function(t){return this.ci(t)[1]},au.Definition={type:"Aggregate",metadata:{generates:!0,changes:!0},params:[{name:"groupby",type:"field",array:!0},{name:"ops",type:"enum",array:!0,values:Js},{name:"aggregate_params",type:"number",null:!0,array:!0},{name:"fields",type:"field",null:!0,array:!0},{name:"as",type:"string",null:!0,array:!0},{name:"drop",type:"boolean",default:!0},{name:"cross",type:"boolean",default:!1},{name:"key",type:"field"}]},dt(au,Ja,{transform(t,e){const n=this,r=e.fork(e.NO_SOURCE|e.NO_FIELDS),i=t.modified();return n.stamp=r.stamp,n.value&&(i||e.modified(n._inputs,!0))?(n._prev=n.value,n.value=i?n.init(t):Object.create(null),e.visit(e.SOURCE,(t=>n.add(t)))):(n.value=n.value||n.init(t),e.visit(e.REM,(t=>n.rem(t))),e.visit(e.ADD,(t=>n.add(t)))),r.modifies(n._outputs),n._drop=!1!==t.drop,t.cross&&n._dims.length>1&&(n._drop=!1,n.cross()),e.clean()&&n._drop&&r.clean(!0).runAfter((()=>this.clean())),n.changes(r)},cross(){const t=this,e=t.value,n=t._dnames,r=n.map((()=>({}))),i=n.length;function o(t){let e,o,a,s;for(e in t)for(a=t[e].tuple,o=0;o{const e=n(t);return a(t),i.push(e),e})),this.cellkey=t.key?t.key:Hs(this._dims),this._countOnly=!0,this._counts=[],this._measures=[];const u=t.fields||[null],l=t.ops||["count"],c=t.aggregate_params||[null],f=t.as||[],h=u.length,d={};let p,g,m,y,v,_,x;for(h!==l.length&&s("Unmatched number of fields and aggregate ops."),x=0;xru(t,t.field))),Object.create(null)},cellkey:Hs(),cell(t,e){let n=this.value[t];return n?0===n.num&&this._drop&&n.stampo.push(t),remove:t=>a[r(t)]=++s,size:()=>i.length,data:(t,e)=>(s&&(i=i.filter((t=>!a[r(t)])),a={},s=0),e&&t&&i.sort(t),o.length&&(i=t?At(t,i,o.sort(t)):i.concat(o),o=[]),i)}}function lu(t){Ja.call(this,[],t)}function cu(t){Sa.call(this,null,fu,t)}function fu(t){return this.value&&!t.modified()?this.value:Q(t.fields,t.orders)}function hu(t){Ja.call(this,null,t)}function du(t){Ja.call(this,null,t)}su.Definition={type:"Bin",metadata:{modifies:!0},params:[{name:"field",type:"field",required:!0},{name:"interval",type:"boolean",default:!0},{name:"anchor",type:"number"},{name:"maxbins",type:"number",default:20},{name:"base",type:"number",default:10},{name:"divide",type:"number",array:!0,default:[5,2]},{name:"extent",type:"number",array:!0,length:2,required:!0},{name:"span",type:"number"},{name:"step",type:"number"},{name:"steps",type:"number",array:!0},{name:"minstep",type:"number",default:0},{name:"nice",type:"boolean",default:!0},{name:"name",type:"string"},{name:"as",type:"string",array:!0,length:2,default:["bin0","bin1"]}]},dt(su,Ja,{transform(t,e){const n=!1!==t.interval,i=this._bins(t),o=i.start,a=i.step,s=t.as||["bin0","bin1"],u=s[0],l=s[1];let c;return c=t.modified()?(e=e.reflow(!0)).SOURCE:e.modified(r(t.field))?e.ADD_MOD:e.ADD,e.visit(c,n?t=>{const e=i(t);t[u]=e,t[l]=null==e?null:o+a*(1+(e-o)/a)}:t=>t[u]=i(t)),e.modifies(n?s:u)},_bins(t){if(this.value&&!t.modified())return this.value;const i=t.field,o=is(t),a=o.step;let s,u,l=o.start,c=l+Math.ceil((o.stop-l)/a)*a;null!=(s=t.anchor)&&(u=s-(l+a*Math.floor((s-l)/a)),l+=u,c+=u);const f=function(t){let e=S(i(t));return null==e?null:ec?1/0:(e=Math.max(l,Math.min(e,c-a)),l+a*Math.floor(1e-14+(e-l)/a))};return f.start=l,f.stop=o.stop,f.step=a,this.value=e(f,r(i),t.name||"bin_"+n(i))}}),lu.Definition={type:"Collect",metadata:{source:!0},params:[{name:"sort",type:"compare"}]},dt(lu,Ja,{transform(t,e){const n=e.fork(e.ALL),r=uu(ya,this.value,n.materialize(n.ADD).add),i=t.sort,o=e.changed()||i&&(t.modified("sort")||e.modified(i.fields));return n.visit(n.REM,r.remove),this.modified(o),this.value=n.source=r.data(ka(i),o),e.source&&e.source.root&&(this.value.root=e.source.root),n}}),dt(cu,Sa),hu.Definition={type:"CountPattern",metadata:{generates:!0,changes:!0},params:[{name:"field",type:"field",required:!0},{name:"case",type:"enum",values:["upper","lower","mixed"],default:"mixed"},{name:"pattern",type:"string",default:'[\\w"]+'},{name:"stopwords",type:"string",default:""},{name:"as",type:"string",array:!0,length:2,default:["text","count"]}]},dt(hu,Ja,{transform(t,e){const n=e=>n=>{for(var r,i=function(t,e,n){switch(e){case"upper":t=t.toUpperCase();break;case"lower":t=t.toLowerCase()}return t.match(n)}(s(n),t.case,o)||[],u=0,l=i.length;ui[t]=1+(i[t]||0))),c=n((t=>i[t]-=1));return r?e.visit(e.SOURCE,l):(e.visit(e.ADD,l),e.visit(e.REM,c)),this._finish(e,u)},_parameterCheck(t,e){let n=!1;return!t.modified("stopwords")&&this._stop||(this._stop=new RegExp("^"+(t.stopwords||"")+"$","i"),n=!0),!t.modified("pattern")&&this._match||(this._match=new RegExp(t.pattern||"[\\w']+","g"),n=!0),(t.modified("field")||e.modified(t.field.fields))&&(n=!0),n&&(this._counts={}),n},_finish(t,e){const n=this._counts,r=this._tuples||(this._tuples={}),i=e[0],o=e[1],a=t.fork(t.NO_SOURCE|t.NO_FIELDS);let s,u,l;for(s in n)u=r[s],l=n[s]||0,!u&&l?(r[s]=u=_a({}),u[i]=s,u[o]=l,a.add.push(u)):0===l?(u&&a.rem.push(u),n[s]=null,r[s]=null):u[o]!==l&&(u[o]=l,a.mod.push(u));return a.modifies(e)}}),du.Definition={type:"Cross",metadata:{generates:!0},params:[{name:"filter",type:"expr"},{name:"as",type:"string",array:!0,length:2,default:["a","b"]}]},dt(du,Ja,{transform(t,e){const n=e.fork(e.NO_SOURCE),r=t.as||["a","b"],i=r[0],o=r[1],a=!this.value||e.changed(e.ADD_REM)||t.modified("as")||t.modified("filter");let s=this.value;return a?(s&&(n.rem=s),s=e.materialize(e.SOURCE).source,n.add=this.value=function(t,e,n,r){for(var i,o,a=[],s={},u=t.length,l=0;lmu(t,e)))):typeof r[n]===gu&&r[n](t[n]);return r}function yu(t){Ja.call(this,null,t)}const vu=[{key:{function:"normal"},params:[{name:"mean",type:"number",default:0},{name:"stdev",type:"number",default:1}]},{key:{function:"lognormal"},params:[{name:"mean",type:"number",default:0},{name:"stdev",type:"number",default:1}]},{key:{function:"uniform"},params:[{name:"min",type:"number",default:0},{name:"max",type:"number",default:1}]},{key:{function:"kde"},params:[{name:"field",type:"field",required:!0},{name:"from",type:"data"},{name:"bandwidth",type:"number",default:0}]}],_u={key:{function:"mixture"},params:[{name:"distributions",type:"param",array:!0,params:vu},{name:"weights",type:"number",array:!0}]};function xu(t,e){return t?t.map(((t,r)=>e[r]||n(t))):null}function bu(t,e,n){const r=[],i=t=>t(u);let o,a,s,u,l,c;if(null==e)r.push(t.map(n));else for(o={},a=0,s=t.length;at.materialize(t.SOURCE).source}(e)),i=t.steps||t.minsteps||25,o=t.steps||t.maxsteps||200;let a=t.method||"pdf";"pdf"!==a&&"cdf"!==a&&s("Invalid density method: "+a),t.extent||r.data||s("Missing density extent parameter."),a=r[a];const u=t.as||["value","density"],l=Is(a,t.extent||at(r.data()),i,o).map((t=>{const e={};return e[u[0]]=t[0],e[u[1]]=t[1],_a(e)}));this.value&&(n.rem=this.value),this.value=n.add=n.source=l}return n}});function wu(t){Ja.call(this,null,t)}wu.Definition={type:"DotBin",metadata:{modifies:!0},params:[{name:"field",type:"field",required:!0},{name:"groupby",type:"field",array:!0},{name:"step",type:"number"},{name:"smooth",type:"boolean",default:!1},{name:"as",type:"string",default:"bin"}]};function ku(t){Sa.call(this,null,Au,t),this.modified(!0)}function Au(t){const i=t.expr;return this.value&&!t.modified("expr")?this.value:e((e=>i(e,t)),r(i),n(i))}function Mu(t){Ja.call(this,[void 0,void 0],t)}function Eu(t,e){Sa.call(this,t),this.parent=e,this.count=0}function Du(t){Ja.call(this,{},t),this._keys=ft();const e=this._targets=[];e.active=0,e.forEach=t=>{for(let n=0,r=e.active;nl(t))):l(t.name,t.as)}function Su(t){Ja.call(this,ft(),t)}function $u(t){Ja.call(this,[],t)}function Tu(t){Ja.call(this,[],t)}function Bu(t){Ja.call(this,null,t)}function zu(t){Ja.call(this,[],t)}dt(wu,Ja,{transform(t,e){if(this.value&&!t.modified()&&!e.changed())return e;const n=e.materialize(e.SOURCE).source,r=bu(e.source,t.groupby,f),i=t.smooth||!1,o=t.field,a=t.step||((t,e)=>Dt(at(t,e))/30)(n,o),s=ka(((t,e)=>o(t)-o(e))),u=t.as||"bin",l=r.length;let c,h=1/0,d=-1/0,p=0;for(;pd&&(d=e),t[++c][u]=e}return this.value={start:h,stop:d,step:a},e.reflow(!0).modifies(u)}}),dt(ku,Sa),Mu.Definition={type:"Extent",metadata:{},params:[{name:"field",type:"field",required:!0}]},dt(Mu,Ja,{transform(t,e){const r=this.value,i=t.field,o=e.changed()||e.modified(i.fields)||t.modified("field");let a=r[0],s=r[1];if((o||null==a)&&(a=1/0,s=-1/0),e.visit(o?e.SOURCE:e.ADD,(t=>{const e=S(i(t));null!=e&&(es&&(s=e))})),!Number.isFinite(a)||!Number.isFinite(s)){let t=n(i);t&&(t=` for field "${t}"`),e.dataflow.warn(`Infinite extent${t}: [${a}, ${s}]`),a=s=void 0}this.value=[a,s]}}),dt(Eu,Sa,{connect(t){return this.detachSubflow=t.detachSubflow,this.targets().add(t),t.source=this},add(t){this.count+=1,this.value.add.push(t)},rem(t){this.count-=1,this.value.rem.push(t)},mod(t){this.value.mod.push(t)},init(t){this.value.init(t,t.NO_SOURCE)},evaluate(){return this.value}}),dt(Du,Ja,{activate(t){this._targets[this._targets.active++]=t},subflow(t,e,n,r){const i=this.value;let o,a,s=lt(i,t)&&i[t];return s?s.value.stampt&&t.count>0));this.initTargets(t)}},initTargets(t){const e=this._targets,n=e.length,r=t?t.length:0;let i=0;for(;ithis.subflow(t,i,e);return this._group=t.group||{},this.initTargets(),e.visit(e.REM,(t=>{const e=ya(t),n=o.get(e);void 0!==n&&(o.delete(e),s(n).rem(t))})),e.visit(e.ADD,(t=>{const e=r(t);o.set(ya(t),e),s(e).add(t)})),a||e.modified(r.fields)?e.visit(e.MOD,(t=>{const e=ya(t),n=o.get(e),i=r(t);n===i?s(i).mod(t):(o.set(e,i),s(n).rem(t),s(i).add(t))})):e.changed(e.MOD)&&e.visit(e.MOD,(t=>{s(o.get(ya(t))).mod(t)})),a&&e.visit(e.REFLOW,(t=>{const e=ya(t),n=o.get(e),i=r(t);n!==i&&(o.set(e,i),s(n).rem(t),s(i).add(t))})),e.clean()?n.runAfter((()=>{this.clean(),o.clean()})):o.empty>n.cleanThreshold&&n.runAfter(o.clean),e}}),dt(Cu,Sa),Su.Definition={type:"Filter",metadata:{changes:!0},params:[{name:"expr",type:"expr",required:!0}]},dt(Su,Ja,{transform(t,e){const n=e.dataflow,r=this.value,i=e.fork(),o=i.add,a=i.rem,s=i.mod,u=t.expr;let l=!0;function c(e){const n=ya(e),i=u(e,t),c=r.get(n);i&&c?(r.delete(n),o.push(e)):i||c?l&&i&&!c&&s.push(e):(r.set(n,1),a.push(e))}return e.visit(e.REM,(t=>{const e=ya(t);r.has(e)?r.delete(e):a.push(t)})),e.visit(e.ADD,(e=>{u(e,t)?o.push(e):r.set(ya(e),1)})),e.visit(e.MOD,c),t.modified()&&(l=!1,e.visit(e.REFLOW,c)),r.empty>n.cleanThreshold&&n.runAfter(r.clean),i}}),$u.Definition={type:"Flatten",metadata:{generates:!0},params:[{name:"fields",type:"field",array:!0,required:!0},{name:"index",type:"string"},{name:"as",type:"string",array:!0}]},dt($u,Ja,{transform(t,e){const n=e.fork(e.NO_SOURCE),r=t.fields,i=xu(r,t.as||[]),o=t.index||null,a=i.length;return n.rem=this.value,e.visit(e.SOURCE,(t=>{const e=r.map((e=>e(t))),s=e.reduce(((t,e)=>Math.max(t,e.length)),0);let u,l,c,f=0;for(;f{for(let e,n=0;ne[r]=n(e,t)))}}),dt(zu,Ja,{transform(t,e){const n=e.fork(e.ALL),r=t.generator;let i,o,a,s=this.value,u=t.size-s.length;if(u>0){for(i=[];--u>=0;)i.push(a=_a(r(t))),s.push(a);n.add=n.add.length?n.materialize(n.ADD).add.concat(i):i}else o=s.slice(0,-u),n.rem=n.rem.length?n.materialize(n.REM).rem.concat(o):o,s=s.slice(-u);return n.source=this.value=s,n}});const Nu={value:"value",median:Ce,mean:function(t,e){let n=0,r=0;if(void 0===e)for(let e of t)null!=e&&(e=+e)>=e&&(++n,r+=e);else{let i=-1;for(let o of t)null!=(o=e(o,++i,t))&&(o=+o)>=o&&(++n,r+=o)}if(n)return r/n},min:ke,max:we},Ou=[];function Ru(t){Ja.call(this,[],t)}function Uu(t){au.call(this,t)}function Lu(t){Ja.call(this,null,t)}function qu(t){Sa.call(this,null,Pu,t)}function Pu(t){return this.value&&!t.modified()?this.value:bt(t.fields,t.flat)}function ju(t){Ja.call(this,[],t),this._pending=null}function Iu(t,e,n){n.forEach(_a);const r=e.fork(e.NO_FIELDS&e.NO_SOURCE);return r.rem=t.value,t.value=r.source=r.add=n,t._pending=null,r.rem.length&&r.clean(!0),r}function Wu(t){Ja.call(this,{},t)}function Hu(t){Sa.call(this,null,Yu,t)}function Yu(t){if(this.value&&!t.modified())return this.value;const e=t.extents,n=e.length;let r,i,o=1/0,a=-1/0;for(r=0;ra&&(a=i[1]);return[o,a]}function Gu(t){Sa.call(this,null,Vu,t)}function Vu(t){return this.value&&!t.modified()?this.value:t.values.reduce(((t,e)=>t.concat(e)),[])}function Xu(t){Ja.call(this,null,t)}function Ju(t){au.call(this,t)}function Zu(t){Du.call(this,t)}function Qu(t){Ja.call(this,null,t)}function Ku(t){Ja.call(this,null,t)}function tl(t){Ja.call(this,null,t)}Ru.Definition={type:"Impute",metadata:{changes:!0},params:[{name:"field",type:"field",required:!0},{name:"key",type:"field",required:!0},{name:"keyvals",array:!0},{name:"groupby",type:"field",array:!0},{name:"method",type:"enum",default:"value",values:["value","mean","median","max","min"]},{name:"value",default:0}]},dt(Ru,Ja,{transform(t,e){var r,i,o,a,u,l,c,f,h,d,p=e.fork(e.ALL),g=function(t){var e,n=t.method||Nu.value;if(null!=Nu[n])return n===Nu.value?(e=void 0!==t.value?t.value:0,()=>e):Nu[n];s("Unrecognized imputation method: "+n)}(t),m=function(t){const e=t.field;return t=>t?e(t):NaN}(t),y=n(t.field),v=n(t.key),_=(t.groupby||[]).map(n),x=function(t,e,n,r){var i,o,a,s,u,l,c,f,h=t=>t(f),d=[],p=r?r.slice():[],g={},m={};for(p.forEach(((t,e)=>g[t]=e+1)),s=0,c=t.length;sn.add(t)))):(i=n.value=n.value||this.init(t),e.visit(e.REM,(t=>n.rem(t))),e.visit(e.ADD,(t=>n.add(t)))),n.changes(),e.visit(e.SOURCE,(t=>{ot(t,i[n.cellkey(t)].tuple)})),e.reflow(r).modifies(this._outputs)},changes(){const t=this._adds,e=this._mods;let n,r;for(n=0,r=this._alen;n{const n=gs(e,u)[l],r=t.counts?e.length:1;Is(n,h||at(e),d,p).forEach((t=>{const n={};for(let t=0;t(this._pending=V(t.data),t=>t.touch(this))));return{async:e}}return n.request(t.url,t.format).then((t=>Iu(this,e,V(t.data))))}}),Wu.Definition={type:"Lookup",metadata:{modifies:!0},params:[{name:"index",type:"index",params:[{name:"from",type:"data",required:!0},{name:"key",type:"field",required:!0}]},{name:"values",type:"field",array:!0},{name:"fields",type:"field",array:!0,required:!0},{name:"as",type:"string",array:!0},{name:"default",default:null}]},dt(Wu,Ja,{transform(t,e){const r=t.fields,i=t.index,o=t.values,a=null==t.default?null:t.default,u=t.modified(),l=r.length;let c,f,h,d=u?e.SOURCE:e.ADD,p=e,g=t.as;return o?(f=o.length,l>1&&!g&&s('Multi-field lookup requires explicit "as" parameter.'),g&&g.length!==l*f&&s('The "as" parameter has too few output field names.'),g=g||o.map(n),c=function(t){for(var e,n,s=0,u=0;se.modified(t.fields))),d|=h?e.MOD:0),e.visit(d,c),p.modifies(g)}}),dt(Hu,Sa),dt(Gu,Sa),dt(Xu,Ja,{transform(t,e){return this.modified(t.modified()),this.value=t,e.fork(e.NO_SOURCE|e.NO_FIELDS)}}),Ju.Definition={type:"Pivot",metadata:{generates:!0,changes:!0},params:[{name:"groupby",type:"field",array:!0},{name:"field",type:"field",required:!0},{name:"value",type:"field",required:!0},{name:"op",type:"enum",values:Js,default:"sum"},{name:"limit",type:"number",default:0},{name:"key",type:"field"}]},dt(Ju,au,{_transform:au.prototype.transform,transform(t,n){return this._transform(function(t,n){const i=t.field,o=t.value,a=("count"===t.op?"__count__":t.op)||"sum",s=r(i).concat(r(o)),u=function(t,e,n){const r={},i=[];return n.visit(n.SOURCE,(e=>{const n=t(e);r[n]||(r[n]=1,i.push(n))})),i.sort(K),e?i.slice(0,e):i}(i,t.limit||0,n);n.changed()&&t.set("__pivot__",null,null,!0);return{key:t.key,groupby:t.groupby,ops:u.map((()=>a)),fields:u.map((t=>function(t,n,r,i){return e((e=>n(e)===t?r(e):NaN),i,t+"")}(t,i,o,s))),as:u.map((t=>t+"")),modified:t.modified.bind(t)}}(t,n),n)}}),dt(Zu,Du,{transform(t,e){const n=t.subflow,i=t.field,o=t=>this.subflow(ya(t),n,e,t);return(t.modified("field")||i&&e.modified(r(i)))&&s("PreFacet does not support field modification."),this.initTargets(),i?(e.visit(e.MOD,(t=>{const e=o(t);i(t).forEach((t=>e.mod(t)))})),e.visit(e.ADD,(t=>{const e=o(t);i(t).forEach((t=>e.add(_a(t))))})),e.visit(e.REM,(t=>{const e=o(t);i(t).forEach((t=>e.rem(t)))}))):(e.visit(e.MOD,(t=>o(t).mod(t))),e.visit(e.ADD,(t=>o(t).add(t))),e.visit(e.REM,(t=>o(t).rem(t)))),e.clean()&&e.runAfter((()=>this.clean())),e}}),Qu.Definition={type:"Project",metadata:{generates:!0,changes:!0},params:[{name:"fields",type:"field",array:!0},{name:"as",type:"string",null:!0,array:!0}]},dt(Qu,Ja,{transform(t,e){const n=e.fork(e.NO_SOURCE),r=t.fields,i=xu(t.fields,t.as||[]),o=r?(t,e)=>function(t,e,n,r){for(let i=0,o=n.length;i{const e=ya(t);n.rem.push(a[e]),a[e]=null})),e.visit(e.ADD,(t=>{const e=o(t,_a({}));a[ya(t)]=e,n.add.push(e)})),e.visit(e.MOD,(t=>{n.mod.push(o(t,a[ya(t)]))})),n}}),dt(Ku,Ja,{transform(t,e){return this.value=t.value,t.modified("value")?e.fork(e.NO_SOURCE|e.NO_FIELDS):e.StopPropagation}}),tl.Definition={type:"Quantile",metadata:{generates:!0,changes:!0},params:[{name:"groupby",type:"field",array:!0},{name:"field",type:"field",required:!0},{name:"probs",type:"number",array:!0},{name:"step",type:"number",default:.01},{name:"as",type:"string",array:!0,default:["prob","value"]}]};function el(t){Ja.call(this,null,t)}function nl(t){Ja.call(this,[],t),this.count=0}function rl(t){Ja.call(this,null,t)}function il(t){Ja.call(this,null,t),this.modified(!0)}function ol(t){Ja.call(this,null,t)}dt(tl,Ja,{transform(t,e){const r=e.fork(e.NO_SOURCE|e.NO_FIELDS),i=t.as||["prob","value"];if(this.value&&!t.modified()&&!e.changed())return r.source=this.value,r;const o=bu(e.materialize(e.SOURCE).source,t.groupby,t.field),a=(t.groupby||[]).map(n),s=[],u=t.step||.01,l=t.probs||Se(u/2,1-1e-14,u),c=l.length;return o.forEach((t=>{const e=es(t,l);for(let n=0;n{const e=ya(t);n.rem.push(r[e]),r[e]=null})),e.visit(e.ADD,(t=>{const e=xa(t);r[ya(t)]=e,n.add.push(e)})),e.visit(e.MOD,(t=>{const e=r[ya(t)];for(const r in t)e[r]=t[r],n.modifies(r);n.mod.push(e)}))),n}}),nl.Definition={type:"Sample",metadata:{},params:[{name:"size",type:"number",default:1e3}]},dt(nl,Ja,{transform(e,n){const r=n.fork(n.NO_SOURCE),i=e.modified("size"),o=e.size,a=this.value.reduce(((t,e)=>(t[ya(e)]=1,t)),{});let s=this.value,u=this.count,l=0;function c(e){let n,i;s.length=l&&(n=s[i],a[ya(n)]&&r.rem.push(n),s[i]=e)),++u}if(n.rem.length&&(n.visit(n.REM,(t=>{const e=ya(t);a[e]&&(a[e]=-1,r.rem.push(t)),--u})),s=s.filter((t=>-1!==a[ya(t)]))),(n.rem.length||i)&&s.length{a[ya(t)]||c(t)})),l=-1),i&&s.length>o){const t=s.length-o;for(let e=0;e{a[ya(t)]&&r.mod.push(t)})),n.add.length&&n.visit(n.ADD,c),(n.add.length||l<0)&&(r.add=s.filter((t=>!a[ya(t)]))),this.count=u,this.value=r.source=s,r}}),rl.Definition={type:"Sequence",metadata:{generates:!0,changes:!0},params:[{name:"start",type:"number",required:!0},{name:"stop",type:"number",required:!0},{name:"step",type:"number",default:1},{name:"as",type:"string",default:"data"}]},dt(rl,Ja,{transform(t,e){if(this.value&&!t.modified())return;const n=e.materialize().fork(e.MOD),r=t.as||"data";return n.rem=this.value?e.rem.concat(this.value):e.rem,this.value=Se(t.start,t.stop,t.step||1).map((t=>{const e={};return e[r]=t,_a(e)})),n.add=e.add.concat(this.value),n}}),dt(il,Ja,{transform(t,e){return this.value=e.source,e.changed()?e.fork(e.NO_SOURCE|e.NO_FIELDS):e.StopPropagation}});const al=["unit0","unit1"];function sl(t){Ja.call(this,ft(),t)}function ul(t){Ja.call(this,null,t)}ol.Definition={type:"TimeUnit",metadata:{modifies:!0},params:[{name:"field",type:"field",required:!0},{name:"interval",type:"boolean",default:!0},{name:"units",type:"enum",values:Kn,array:!0},{name:"step",type:"number",default:1},{name:"maxbins",type:"number",default:40},{name:"extent",type:"date",array:!0},{name:"timezone",type:"enum",default:"local",values:["local","utc"]},{name:"as",type:"string",array:!0,length:2,default:al}]},dt(ol,Ja,{transform(t,e){const n=t.field,i=!1!==t.interval,o="utc"===t.timezone,a=this._floor(t,e),s=(o?Fr:Cr)(a.unit).offset,u=t.as||al,l=u[0],c=u[1],f=a.step;let h=a.start||1/0,d=a.stop||-1/0,p=e.ADD;return(t.modified()||e.changed(e.REM)||e.modified(r(n)))&&(p=(e=e.reflow(!0)).SOURCE,h=1/0,d=-1/0),e.visit(p,(t=>{const e=n(t);let r,o;null==e?(t[l]=null,i&&(t[c]=null)):(t[l]=r=o=a(e),i&&(t[c]=o=s(r,f)),rd&&(d=o))})),a.start=h,a.stop=d,e.modifies(i?u:l)},_floor(t,e){const n="utc"===t.timezone,{units:r,step:i}=t.units?{units:t.units,step:t.step||1}:Jr({extent:t.extent||at(e.materialize(e.SOURCE).source,t.field),maxbins:t.maxbins}),o=er(r),a=this.value||{},s=(n?Mr:wr)(o,i);return s.unit=F(o),s.units=o,s.step=i,s.start=a.start,s.stop=a.stop,this.value=s}}),dt(sl,Ja,{transform(t,e){const n=e.dataflow,r=t.field,i=this.value,o=t=>i.set(r(t),t);let a=!0;return t.modified("field")||e.modified(r.fields)?(i.clear(),e.visit(e.SOURCE,o)):e.changed()?(e.visit(e.REM,(t=>i.delete(r(t)))),e.visit(e.ADD,o)):a=!1,this.modified(a),i.empty>n.cleanThreshold&&n.runAfter(i.clean),e.fork()}}),dt(ul,Ja,{transform(t,e){(!this.value||t.modified("field")||t.modified("sort")||e.changed()||t.sort&&e.modified(t.sort.fields))&&(this.value=(t.sort?e.source.slice().sort(ka(t.sort)):e.source).map(t.field))}});const ll={row_number:function(){return{next:t=>t.index+1}},rank:function(){let t;return{init:()=>t=1,next:e=>{const n=e.index,r=e.data;return n&&e.compare(r[n-1],r[n])?t=n+1:t}}},dense_rank:function(){let t;return{init:()=>t=1,next:e=>{const n=e.index,r=e.data;return n&&e.compare(r[n-1],r[n])?++t:t}}},percent_rank:function(){const t=ll.rank(),e=t.next;return{init:t.init,next:t=>(e(t)-1)/(t.data.length-1)}},cume_dist:function(){let t;return{init:()=>t=0,next:e=>{const n=e.data,r=e.compare;let i=e.index;if(t0||s("ntile num must be greater than zero.");const n=ll.cume_dist(),r=n.next;return{init:n.init,next:t=>Math.ceil(e*r(t))}},lag:function(t,e){return e=+e||1,{next:n=>{const r=n.index-e;return r>=0?t(n.data[r]):null}}},lead:function(t,e){return e=+e||1,{next:n=>{const r=n.index+e,i=n.data;return rt(e.data[e.i0])}},last_value:function(t){return{next:e=>t(e.data[e.i1-1])}},nth_value:function(t,e){return(e=+e)>0||s("nth_value nth must be greater than zero."),{next:n=>{const r=n.i0+(e-1);return re=null,next:n=>{const r=t(n.data[n.index]);return null!=r?e=r:e}}},next_value:function(t){let e,n;return{init:()=>(e=null,n=-1),next:r=>{const i=r.data;return r.index<=n?e:(n=function(t,e,n){for(let r=e.length;nf[t]=1))}y(t.sort),e.forEach(((t,e)=>{const r=i[e],f=o[e],v=a[e]||null,_=n(r),x=Ys(t,_,u[e]);if(y(r),l.push(x),lt(ll,t))c.push(function(t,e,n,r){const i=ll[t](e,n);return{init:i.init||h,update:function(t,e){e[r]=i.next(t)}}}(t,r,f,x));else{if(null==r&&"count"!==t&&s("Null aggregate field specified."),"count"===t)return void p.push(x);m=!1;let e=d[_];e||(e=d[_]=[],e.field=r,g.push(e)),e.push(Zs(t,v,x))}})),(p.length||g.length)&&(this.cell=function(t,e,n){t=t.map((t=>ru(t,t.field)));const r={num:0,agg:null,store:!1,count:e};if(!n)for(var i=t.length,o=r.agg=Array(i),a=0;a0&&!i(o[n],o[n-1])&&(t.i0=e.left(o,o[n])),rt.init())),this.cell&&this.cell.init()},hl.update=function(t,e){const n=this.cell,r=this.windows,i=t.data,o=r&&r.length;let a;if(n){for(a=t.p0;athis.group(i(t));let a=this.state;a&&!n||(a=this.state=new fl(t)),n||e.modified(a.inputs)?(this.value={},e.visit(e.SOURCE,(t=>o(t).add(t)))):(e.visit(e.REM,(t=>o(t).remove(t))),e.visit(e.ADD,(t=>o(t).add(t))));for(let e=0,n=this._mlen;e=1?Cl:t<=-1?-Cl:Math.asin(t)}const $l=Math.PI,Tl=2*$l,Bl=1e-6,zl=Tl-Bl;function Nl(t){this._+=t[0];for(let e=1,n=t.length;e=0))throw new Error(`invalid digits: ${t}`);if(e>15)return Nl;const n=10**e;return function(t){this._+=t[0];for(let e=1,r=t.length;eBl)if(Math.abs(c*s-u*l)>Bl&&i){let h=n-o,d=r-a,p=s*s+u*u,g=h*h+d*d,m=Math.sqrt(p),y=Math.sqrt(f),v=i*Math.tan(($l-Math.acos((p+f-g)/(2*m*y)))/2),_=v/y,x=v/m;Math.abs(_-1)>Bl&&this._append`L${t+_*l},${e+_*c}`,this._append`A${i},${i},0,0,${+(c*h>l*d)},${this._x1=t+x*s},${this._y1=e+x*u}`}else this._append`L${this._x1=t},${this._y1=e}`;else;}arc(t,e,n,r,i,o){if(t=+t,e=+e,o=!!o,(n=+n)<0)throw new Error(`negative radius: ${n}`);let a=n*Math.cos(r),s=n*Math.sin(r),u=t+a,l=e+s,c=1^o,f=o?r-i:i-r;null===this._x1?this._append`M${u},${l}`:(Math.abs(this._x1-u)>Bl||Math.abs(this._y1-l)>Bl)&&this._append`L${u},${l}`,n&&(f<0&&(f=f%Tl+Tl),f>zl?this._append`A${n},${n},0,1,${c},${t-a},${e-s}A${n},${n},0,1,${c},${this._x1=u},${this._y1=l}`:f>Bl&&this._append`A${n},${n},0,${+(f>=$l)},${c},${this._x1=t+n*Math.cos(i)},${this._y1=e+n*Math.sin(i)}`)}rect(t,e,n,r){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+e}h${n=+n}v${+r}h${-n}Z`}toString(){return this._}};function Rl(){return new Ol}function Ul(t){let e=3;return t.digits=function(n){if(!arguments.length)return e;if(null==n)e=null;else{const t=Math.floor(n);if(!(t>=0))throw new RangeError(`invalid digits: ${n}`);e=t}return t},()=>new Ol(e)}function Ll(t){return t.innerRadius}function ql(t){return t.outerRadius}function Pl(t){return t.startAngle}function jl(t){return t.endAngle}function Il(t){return t&&t.padAngle}function Wl(t,e,n,r,i,o,a){var s=t-n,u=e-r,l=(a?o:-o)/Ml(s*s+u*u),c=l*u,f=-l*s,h=t+c,d=e+f,p=n+c,g=r+f,m=(h+p)/2,y=(d+g)/2,v=p-h,_=g-d,x=v*v+_*_,b=i-o,w=h*g-p*d,k=(_<0?-1:1)*Ml(wl(0,b*b*x-w*w)),A=(w*_-v*k)/x,M=(-w*v-_*k)/x,E=(w*_+v*k)/x,D=(-w*v+_*k)/x,C=A-m,F=M-y,S=E-m,$=D-y;return C*C+F*F>S*S+$*$&&(A=E,M=D),{cx:A,cy:M,x01:-c,y01:-f,x11:A*(i/b-1),y11:M*(i/b-1)}}function Hl(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function Yl(t){this._context=t}function Gl(t){return new Yl(t)}function Vl(t){return t[0]}function Xl(t){return t[1]}function Jl(t,e){var n=vl(!0),r=null,i=Gl,o=null,a=Ul(s);function s(s){var u,l,c,f=(s=Hl(s)).length,h=!1;for(null==r&&(o=i(c=a())),u=0;u<=f;++u)!(u=f;--h)s.point(y[h],v[h]);s.lineEnd(),s.areaEnd()}m&&(y[c]=+t(d,c,l),v[c]=+e(d,c,l),s.point(r?+r(d,c,l):y[c],n?+n(d,c,l):v[c]))}if(p)return s=null,p+""||null}function c(){return Jl().defined(i).curve(a).context(o)}return t="function"==typeof t?t:void 0===t?Vl:vl(+t),e="function"==typeof e?e:vl(void 0===e?0:+e),n="function"==typeof n?n:void 0===n?Xl:vl(+n),l.x=function(e){return arguments.length?(t="function"==typeof e?e:vl(+e),r=null,l):t},l.x0=function(e){return arguments.length?(t="function"==typeof e?e:vl(+e),l):t},l.x1=function(t){return arguments.length?(r=null==t?null:"function"==typeof t?t:vl(+t),l):r},l.y=function(t){return arguments.length?(e="function"==typeof t?t:vl(+t),n=null,l):e},l.y0=function(t){return arguments.length?(e="function"==typeof t?t:vl(+t),l):e},l.y1=function(t){return arguments.length?(n=null==t?null:"function"==typeof t?t:vl(+t),l):n},l.lineX0=l.lineY0=function(){return c().x(t).y(e)},l.lineY1=function(){return c().x(t).y(n)},l.lineX1=function(){return c().x(r).y(e)},l.defined=function(t){return arguments.length?(i="function"==typeof t?t:vl(!!t),l):i},l.curve=function(t){return arguments.length?(a=t,null!=o&&(s=a(o)),l):a},l.context=function(t){return arguments.length?(null==t?o=s=null:s=a(o=t),l):o},l}Rl.prototype=Ol.prototype,Yl.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:this._context.lineTo(t,e)}}};var Ql={draw(t,e){const n=Ml(e/Dl);t.moveTo(n,0),t.arc(0,0,n,0,Fl)}};function Kl(){}function tc(t,e,n){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+e)/6,(t._y0+4*t._y1+n)/6)}function ec(t){this._context=t}function nc(t){this._context=t}function rc(t){this._context=t}function ic(t,e){this._basis=new ec(t),this._beta=e}ec.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:tc(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:tc(this,t,e)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}},nc.prototype={areaStart:Kl,areaEnd:Kl,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x2=t,this._y2=e;break;case 1:this._point=2,this._x3=t,this._y3=e;break;case 2:this._point=3,this._x4=t,this._y4=e,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+e)/6);break;default:tc(this,t,e)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}},rc.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var n=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+e)/6;this._line?this._context.lineTo(n,r):this._context.moveTo(n,r);break;case 3:this._point=4;default:tc(this,t,e)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}},ic.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,e=this._y,n=t.length-1;if(n>0)for(var r,i=t[0],o=e[0],a=t[n]-i,s=e[n]-o,u=-1;++u<=n;)r=u/n,this._basis.point(this._beta*t[u]+(1-this._beta)*(i+r*a),this._beta*e[u]+(1-this._beta)*(o+r*s));this._x=this._y=null,this._basis.lineEnd()},point:function(t,e){this._x.push(+t),this._y.push(+e)}};var oc=function t(e){function n(t){return 1===e?new ec(t):new ic(t,e)}return n.beta=function(e){return t(+e)},n}(.85);function ac(t,e,n){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-e),t._y2+t._k*(t._y1-n),t._x2,t._y2)}function sc(t,e){this._context=t,this._k=(1-e)/6}sc.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:ac(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2,this._x1=t,this._y1=e;break;case 2:this._point=3;default:ac(this,t,e)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var uc=function t(e){function n(t){return new sc(t,e)}return n.tension=function(e){return t(+e)},n}(0);function lc(t,e){this._context=t,this._k=(1-e)/6}lc.prototype={areaStart:Kl,areaEnd:Kl,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:ac(this,t,e)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var cc=function t(e){function n(t){return new lc(t,e)}return n.tension=function(e){return t(+e)},n}(0);function fc(t,e){this._context=t,this._k=(1-e)/6}fc.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:ac(this,t,e)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var hc=function t(e){function n(t){return new fc(t,e)}return n.tension=function(e){return t(+e)},n}(0);function dc(t,e,n){var r=t._x1,i=t._y1,o=t._x2,a=t._y2;if(t._l01_a>El){var s=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,u=3*t._l01_a*(t._l01_a+t._l12_a);r=(r*s-t._x0*t._l12_2a+t._x2*t._l01_2a)/u,i=(i*s-t._y0*t._l12_2a+t._y2*t._l01_2a)/u}if(t._l23_a>El){var l=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,c=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*l+t._x1*t._l23_2a-e*t._l12_2a)/c,a=(a*l+t._y1*t._l23_2a-n*t._l12_2a)/c}t._context.bezierCurveTo(r,i,o,a,t._x2,t._y2)}function pc(t,e){this._context=t,this._alpha=e}pc.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){if(t=+t,e=+e,this._point){var n=this._x2-t,r=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(n*n+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3;default:dc(this,t,e)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var gc=function t(e){function n(t){return e?new pc(t,e):new sc(t,0)}return n.alpha=function(e){return t(+e)},n}(.5);function mc(t,e){this._context=t,this._alpha=e}mc.prototype={areaStart:Kl,areaEnd:Kl,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,e){if(t=+t,e=+e,this._point){var n=this._x2-t,r=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(n*n+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:dc(this,t,e)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var yc=function t(e){function n(t){return e?new mc(t,e):new lc(t,0)}return n.alpha=function(e){return t(+e)},n}(.5);function vc(t,e){this._context=t,this._alpha=e}vc.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){if(t=+t,e=+e,this._point){var n=this._x2-t,r=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(n*n+r*r,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:dc(this,t,e)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var _c=function t(e){function n(t){return e?new vc(t,e):new fc(t,0)}return n.alpha=function(e){return t(+e)},n}(.5);function xc(t){this._context=t}function bc(t){return t<0?-1:1}function wc(t,e,n){var r=t._x1-t._x0,i=e-t._x1,o=(t._y1-t._y0)/(r||i<0&&-0),a=(n-t._y1)/(i||r<0&&-0),s=(o*i+a*r)/(r+i);return(bc(o)+bc(a))*Math.min(Math.abs(o),Math.abs(a),.5*Math.abs(s))||0}function kc(t,e){var n=t._x1-t._x0;return n?(3*(t._y1-t._y0)/n-e)/2:e}function Ac(t,e,n){var r=t._x0,i=t._y0,o=t._x1,a=t._y1,s=(o-r)/3;t._context.bezierCurveTo(r+s,i+s*e,o-s,a-s*n,o,a)}function Mc(t){this._context=t}function Ec(t){this._context=new Dc(t)}function Dc(t){this._context=t}function Cc(t){this._context=t}function Fc(t){var e,n,r=t.length-1,i=new Array(r),o=new Array(r),a=new Array(r);for(i[0]=0,o[0]=2,a[0]=t[0]+2*t[1],e=1;e=0;--e)i[e]=(a[e]-i[e+1])/o[e];for(o[r-1]=(t[r]+i[r-1])/2,e=0;e=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,e),this._context.lineTo(t,e);else{var n=this._x*(1-this._t)+t*this._t;this._context.lineTo(n,this._y),this._context.lineTo(n,e)}}this._x=t,this._y=e}};const Tc=()=>"undefined"!=typeof Image?Image:null;function Bc(t,e){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(e).domain(t)}return this}function zc(t,e){switch(arguments.length){case 0:break;case 1:"function"==typeof t?this.interpolator(t):this.range(t);break;default:this.domain(t),"function"==typeof e?this.interpolator(e):this.range(e)}return this}const Nc=Symbol("implicit");function Oc(){var t=new ue,e=[],n=[],r=Nc;function i(i){let o=t.get(i);if(void 0===o){if(r!==Nc)return r;t.set(i,o=e.push(i)-1)}return n[o%n.length]}return i.domain=function(n){if(!arguments.length)return e.slice();e=[],t=new ue;for(const r of n)t.has(r)||t.set(r,e.push(r)-1);return i},i.range=function(t){return arguments.length?(n=Array.from(t),i):n.slice()},i.unknown=function(t){return arguments.length?(r=t,i):r},i.copy=function(){return Oc(e,n).unknown(r)},Bc.apply(i,arguments),i}function Rc(t,e,n){t.prototype=e.prototype=n,n.constructor=t}function Uc(t,e){var n=Object.create(t.prototype);for(var r in e)n[r]=e[r];return n}function Lc(){}var qc=.7,Pc=1/qc,jc="\\s*([+-]?\\d+)\\s*",Ic="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",Wc="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",Hc=/^#([0-9a-f]{3,8})$/,Yc=new RegExp(`^rgb\\(${jc},${jc},${jc}\\)$`),Gc=new RegExp(`^rgb\\(${Wc},${Wc},${Wc}\\)$`),Vc=new RegExp(`^rgba\\(${jc},${jc},${jc},${Ic}\\)$`),Xc=new RegExp(`^rgba\\(${Wc},${Wc},${Wc},${Ic}\\)$`),Jc=new RegExp(`^hsl\\(${Ic},${Wc},${Wc}\\)$`),Zc=new RegExp(`^hsla\\(${Ic},${Wc},${Wc},${Ic}\\)$`),Qc={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};function Kc(){return this.rgb().formatHex()}function tf(){return this.rgb().formatRgb()}function ef(t){var e,n;return t=(t+"").trim().toLowerCase(),(e=Hc.exec(t))?(n=e[1].length,e=parseInt(e[1],16),6===n?nf(e):3===n?new sf(e>>8&15|e>>4&240,e>>4&15|240&e,(15&e)<<4|15&e,1):8===n?rf(e>>24&255,e>>16&255,e>>8&255,(255&e)/255):4===n?rf(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|240&e,((15&e)<<4|15&e)/255):null):(e=Yc.exec(t))?new sf(e[1],e[2],e[3],1):(e=Gc.exec(t))?new sf(255*e[1]/100,255*e[2]/100,255*e[3]/100,1):(e=Vc.exec(t))?rf(e[1],e[2],e[3],e[4]):(e=Xc.exec(t))?rf(255*e[1]/100,255*e[2]/100,255*e[3]/100,e[4]):(e=Jc.exec(t))?df(e[1],e[2]/100,e[3]/100,1):(e=Zc.exec(t))?df(e[1],e[2]/100,e[3]/100,e[4]):Qc.hasOwnProperty(t)?nf(Qc[t]):"transparent"===t?new sf(NaN,NaN,NaN,0):null}function nf(t){return new sf(t>>16&255,t>>8&255,255&t,1)}function rf(t,e,n,r){return r<=0&&(t=e=n=NaN),new sf(t,e,n,r)}function of(t){return t instanceof Lc||(t=ef(t)),t?new sf((t=t.rgb()).r,t.g,t.b,t.opacity):new sf}function af(t,e,n,r){return 1===arguments.length?of(t):new sf(t,e,n,null==r?1:r)}function sf(t,e,n,r){this.r=+t,this.g=+e,this.b=+n,this.opacity=+r}function uf(){return`#${hf(this.r)}${hf(this.g)}${hf(this.b)}`}function lf(){const t=cf(this.opacity);return`${1===t?"rgb(":"rgba("}${ff(this.r)}, ${ff(this.g)}, ${ff(this.b)}${1===t?")":`, ${t})`}`}function cf(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function ff(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function hf(t){return((t=ff(t))<16?"0":"")+t.toString(16)}function df(t,e,n,r){return r<=0?t=e=n=NaN:n<=0||n>=1?t=e=NaN:e<=0&&(t=NaN),new mf(t,e,n,r)}function pf(t){if(t instanceof mf)return new mf(t.h,t.s,t.l,t.opacity);if(t instanceof Lc||(t=ef(t)),!t)return new mf;if(t instanceof mf)return t;var e=(t=t.rgb()).r/255,n=t.g/255,r=t.b/255,i=Math.min(e,n,r),o=Math.max(e,n,r),a=NaN,s=o-i,u=(o+i)/2;return s?(a=e===o?(n-r)/s+6*(n0&&u<1?0:a,new mf(a,s,u,t.opacity)}function gf(t,e,n,r){return 1===arguments.length?pf(t):new mf(t,e,n,null==r?1:r)}function mf(t,e,n,r){this.h=+t,this.s=+e,this.l=+n,this.opacity=+r}function yf(t){return(t=(t||0)%360)<0?t+360:t}function vf(t){return Math.max(0,Math.min(1,t||0))}function _f(t,e,n){return 255*(t<60?e+(n-e)*t/60:t<180?n:t<240?e+(n-e)*(240-t)/60:e)}Rc(Lc,ef,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:Kc,formatHex:Kc,formatHex8:function(){return this.rgb().formatHex8()},formatHsl:function(){return pf(this).formatHsl()},formatRgb:tf,toString:tf}),Rc(sf,af,Uc(Lc,{brighter(t){return t=null==t?Pc:Math.pow(Pc,t),new sf(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=null==t?qc:Math.pow(qc,t),new sf(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new sf(ff(this.r),ff(this.g),ff(this.b),cf(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:uf,formatHex:uf,formatHex8:function(){return`#${hf(this.r)}${hf(this.g)}${hf(this.b)}${hf(255*(isNaN(this.opacity)?1:this.opacity))}`},formatRgb:lf,toString:lf})),Rc(mf,gf,Uc(Lc,{brighter(t){return t=null==t?Pc:Math.pow(Pc,t),new mf(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=null==t?qc:Math.pow(qc,t),new mf(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+360*(this.h<0),e=isNaN(t)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*e,i=2*n-r;return new sf(_f(t>=240?t-240:t+120,i,r),_f(t,i,r),_f(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new mf(yf(this.h),vf(this.s),vf(this.l),cf(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=cf(this.opacity);return`${1===t?"hsl(":"hsla("}${yf(this.h)}, ${100*vf(this.s)}%, ${100*vf(this.l)}%${1===t?")":`, ${t})`}`}}));const xf=Math.PI/180,bf=180/Math.PI,wf=.96422,kf=1,Af=.82521,Mf=4/29,Ef=6/29,Df=3*Ef*Ef,Cf=Ef*Ef*Ef;function Ff(t){if(t instanceof $f)return new $f(t.l,t.a,t.b,t.opacity);if(t instanceof Rf)return Uf(t);t instanceof sf||(t=of(t));var e,n,r=Nf(t.r),i=Nf(t.g),o=Nf(t.b),a=Tf((.2225045*r+.7168786*i+.0606169*o)/kf);return r===i&&i===o?e=n=a:(e=Tf((.4360747*r+.3850649*i+.1430804*o)/wf),n=Tf((.0139322*r+.0971045*i+.7141733*o)/Af)),new $f(116*a-16,500*(e-a),200*(a-n),t.opacity)}function Sf(t,e,n,r){return 1===arguments.length?Ff(t):new $f(t,e,n,null==r?1:r)}function $f(t,e,n,r){this.l=+t,this.a=+e,this.b=+n,this.opacity=+r}function Tf(t){return t>Cf?Math.pow(t,1/3):t/Df+Mf}function Bf(t){return t>Ef?t*t*t:Df*(t-Mf)}function zf(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function Nf(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function Of(t,e,n,r){return 1===arguments.length?function(t){if(t instanceof Rf)return new Rf(t.h,t.c,t.l,t.opacity);if(t instanceof $f||(t=Ff(t)),0===t.a&&0===t.b)return new Rf(NaN,0=1?(n=1,e-1):Math.floor(n*e),i=t[r],o=t[r+1],a=r>0?t[r-1]:2*i-o,s=r()=>t;function Kf(t,e){return function(n){return t+n*e}}function th(t,e){var n=e-t;return n?Kf(t,n>180||n<-180?n-360*Math.round(n/360):n):Qf(isNaN(t)?e:t)}function eh(t){return 1==(t=+t)?nh:function(e,n){return n-e?function(t,e,n){return t=Math.pow(t,n),e=Math.pow(e,n)-t,n=1/n,function(r){return Math.pow(t+r*e,n)}}(e,n,t):Qf(isNaN(e)?n:e)}}function nh(t,e){var n=e-t;return n?Kf(t,n):Qf(isNaN(t)?e:t)}var rh=function t(e){var n=eh(e);function r(t,e){var r=n((t=af(t)).r,(e=af(e)).r),i=n(t.g,e.g),o=n(t.b,e.b),a=nh(t.opacity,e.opacity);return function(e){return t.r=r(e),t.g=i(e),t.b=o(e),t.opacity=a(e),t+""}}return r.gamma=t,r}(1);function ih(t){return function(e){var n,r,i=e.length,o=new Array(i),a=new Array(i),s=new Array(i);for(n=0;no&&(i=e.slice(o,i),s[a]?s[a]+=i:s[++a]=i),(n=n[0])===(r=r[0])?s[a]?s[a]+=r:s[++a]=r:(s[++a]=null,u.push({i:a,x:fh(n,r)})),o=ph.lastIndex;return o180?e+=360:e-t>180&&(t+=360),o.push({i:n.push(i(n)+"rotate(",null,r)-2,x:fh(t,e)})):e&&n.push(i(n)+"rotate("+e+r)}(o.rotate,a.rotate,s,u),function(t,e,n,o){t!==e?o.push({i:n.push(i(n)+"skewX(",null,r)-2,x:fh(t,e)}):e&&n.push(i(n)+"skewX("+e+r)}(o.skewX,a.skewX,s,u),function(t,e,n,r,o,a){if(t!==n||e!==r){var s=o.push(i(o)+"scale(",null,",",null,")");a.push({i:s-4,x:fh(t,n)},{i:s-2,x:fh(e,r)})}else 1===n&&1===r||o.push(i(o)+"scale("+n+","+r+")")}(o.scaleX,o.scaleY,a.scaleX,a.scaleY,s,u),o=a=null,function(t){for(var e,n=-1,r=u.length;++ne&&(n=t,t=e,e=n),function(n){return Math.max(t,Math.min(e,n))}}(a[0],a[t-1])),r=t>2?Ih:jh,i=o=null,f}function f(e){return null==e||isNaN(e=+e)?n:(i||(i=r(a.map(t),s,u)))(t(l(e)))}return f.invert=function(n){return l(e((o||(o=r(s,a.map(t),fh)))(n)))},f.domain=function(t){return arguments.length?(a=Array.from(t,Uh),c()):a.slice()},f.range=function(t){return arguments.length?(s=Array.from(t),c()):s.slice()},f.rangeRound=function(t){return s=Array.from(t),u=yh,c()},f.clamp=function(t){return arguments.length?(l=!!t||qh,c()):l!==qh},f.interpolate=function(t){return arguments.length?(u=t,c()):u},f.unknown=function(t){return arguments.length?(n=t,f):n},function(n,r){return t=n,e=r,c()}}function Yh(){return Hh()(qh,qh)}function Gh(t,e,n,r){var i,o=be(t,e,n);switch((r=Re(null==r?",f":r)).type){case"s":var a=Math.max(Math.abs(t),Math.abs(e));return null!=r.precision||isNaN(i=Xe(o,a))||(r.precision=i),We(r,a);case"":case"e":case"g":case"p":case"r":null!=r.precision||isNaN(i=Je(o,Math.max(Math.abs(t),Math.abs(e))))||(r.precision=i-("e"===r.type));break;case"f":case"%":null!=r.precision||isNaN(i=Ve(o))||(r.precision=i-2*("%"===r.type))}return Ie(r)}function Vh(t){var e=t.domain;return t.ticks=function(t){var n=e();return _e(n[0],n[n.length-1],null==t?10:t)},t.tickFormat=function(t,n){var r=e();return Gh(r[0],r[r.length-1],null==t?10:t,n)},t.nice=function(n){null==n&&(n=10);var r,i,o=e(),a=0,s=o.length-1,u=o[a],l=o[s],c=10;for(l0;){if((i=xe(u,l,n))===r)return o[a]=u,o[s]=l,e(o);if(i>0)u=Math.floor(u/i)*i,l=Math.ceil(l/i)*i;else{if(!(i<0))break;u=Math.ceil(u*i)/i,l=Math.floor(l*i)/i}r=i}return t},t}function Xh(t,e){var n,r=0,i=(t=t.slice()).length-1,o=t[r],a=t[i];return a-t(-e,n)}function nd(t){const e=t(Jh,Zh),n=e.domain;let r,i,o=10;function a(){return r=function(t){return t===Math.E?Math.log:10===t&&Math.log10||2===t&&Math.log2||(t=Math.log(t),e=>Math.log(e)/t)}(o),i=function(t){return 10===t?td:t===Math.E?Math.exp:e=>Math.pow(t,e)}(o),n()[0]<0?(r=ed(r),i=ed(i),t(Qh,Kh)):t(Jh,Zh),e}return e.base=function(t){return arguments.length?(o=+t,a()):o},e.domain=function(t){return arguments.length?(n(t),a()):n()},e.ticks=t=>{const e=n();let a=e[0],s=e[e.length-1];const u=s0){for(;f<=h;++f)for(l=1;ls)break;p.push(c)}}else for(;f<=h;++f)for(l=o-1;l>=1;--l)if(c=f>0?l/i(-f):l*i(f),!(cs)break;p.push(c)}2*p.length{if(null==t&&(t=10),null==n&&(n=10===o?"s":","),"function"!=typeof n&&(o%1||null!=(n=Re(n)).precision||(n.trim=!0),n=Ie(n)),t===1/0)return n;const a=Math.max(1,o*t/e.ticks().length);return t=>{let e=t/i(Math.round(r(t)));return e*on(Xh(n(),{floor:t=>i(Math.floor(r(t))),ceil:t=>i(Math.ceil(r(t)))})),e}function rd(t){return function(e){return Math.sign(e)*Math.log1p(Math.abs(e/t))}}function id(t){return function(e){return Math.sign(e)*Math.expm1(Math.abs(e))*t}}function od(t){var e=1,n=t(rd(e),id(e));return n.constant=function(n){return arguments.length?t(rd(e=+n),id(e)):e},Vh(n)}function ad(t){return function(e){return e<0?-Math.pow(-e,t):Math.pow(e,t)}}function sd(t){return t<0?-Math.sqrt(-t):Math.sqrt(t)}function ud(t){return t<0?-t*t:t*t}function ld(t){var e=t(qh,qh),n=1;return e.exponent=function(e){return arguments.length?1===(n=+e)?t(qh,qh):.5===n?t(sd,ud):t(ad(n),ad(1/n)):n},Vh(e)}function cd(){var t=ld(Hh());return t.copy=function(){return Wh(t,cd()).exponent(t.exponent())},Bc.apply(t,arguments),t}function fd(t){return new Date(t)}function hd(t){return t instanceof Date?+t:+new Date(+t)}function dd(t,e,n,r,i,o,a,s,u,l){var c=Yh(),f=c.invert,h=c.domain,d=l(".%L"),p=l(":%S"),g=l("%I:%M"),m=l("%I %p"),y=l("%a %d"),v=l("%b %d"),_=l("%B"),x=l("%Y");function b(t){return(u(t)0?r:1:0}const Td="linear",Bd="log",zd="pow",Nd="sqrt",Od="symlog",Rd="time",Ud="utc",Ld="sequential",qd="diverging",Pd="quantile",jd="quantize",Id="threshold",Wd="ordinal",Hd="point",Yd="band",Gd="bin-ordinal",Vd="continuous",Xd="discrete",Jd="discretizing",Zd="interpolating",Qd="temporal";function Kd(){const t=Oc().unknown(void 0),e=t.domain,n=t.range;let r,i,o=[0,1],a=!1,s=0,u=0,l=.5;function c(){const t=e().length,c=o[1]d+r*t));return n(c?p.reverse():p)}return delete t.unknown,t.domain=function(t){return arguments.length?(e(t),c()):e()},t.range=function(t){return arguments.length?(o=[+t[0],+t[1]],c()):o.slice()},t.rangeRound=function(t){return o=[+t[0],+t[1]],a=!0,c()},t.bandwidth=function(){return i},t.step=function(){return r},t.round=function(t){return arguments.length?(a=!!t,c()):a},t.padding=function(t){return arguments.length?(u=Math.max(0,Math.min(1,t)),s=u,c()):s},t.paddingInner=function(t){return arguments.length?(s=Math.max(0,Math.min(1,t)),c()):s},t.paddingOuter=function(t){return arguments.length?(u=Math.max(0,Math.min(1,t)),c()):u},t.align=function(t){return arguments.length?(l=Math.max(0,Math.min(1,t)),c()):l},t.invertRange=function(t){if(null==t[0]||null==t[1])return;const r=o[1]o[1-r])?void 0:(u=Math.max(0,oe(a,f)-1),l=f===h?u:oe(a,h)-1,f-a[u]>i+1e-10&&++u,r&&(c=u,u=s-l,l=s-c),u>l?void 0:e().slice(u,l+1))},t.invert=function(e){const n=t.invertRange([e,e]);return n?n[0]:n},t.copy=function(){return Kd().domain(e()).range(o).round(a).paddingInner(s).paddingOuter(u).align(l)},c()}function tp(t){const e=t.copy;return t.padding=t.paddingOuter,delete t.paddingInner,t.copy=function(){return tp(e())},t}var ep=Array.prototype.map;const np=Array.prototype.slice;const rp=new Map,ip=Symbol("vega_scale");function op(t){return t[ip]=!0,t}function ap(t,e,n){return arguments.length>1?(rp.set(t,function(t,e,n){const r=function(){const n=e();return n.invertRange||(n.invertRange=n.invert?function(t){return function(e){let n,r=e[0],i=e[1];return i=s&&n[o]<=u&&(l<0&&(l=o),r=o);if(!(l<0))return s=t.invertExtent(n[l]),u=t.invertExtent(n[r]),[void 0===s[0]?s[1]:s[0],void 0===u[1]?u[0]:u[1]]}}(n):void 0),n.type=t,op(n)};return r.metadata=Bt(V(n)),r}(t,e,n)),this):sp(t)?rp.get(t):void 0}function sp(t){return rp.has(t)}function up(t,e){const n=rp.get(t);return n&&n.metadata[e]}function lp(t){return up(t,Vd)}function cp(t){return up(t,Xd)}function fp(t){return up(t,Jd)}function hp(t){return up(t,Bd)}function dp(t){return up(t,Zd)}function pp(t){return up(t,Pd)}ap("identity",(function t(e){var n;function r(t){return null==t||isNaN(t=+t)?n:t}return r.invert=r,r.domain=r.range=function(t){return arguments.length?(e=Array.from(t,Uh),r):e.slice()},r.unknown=function(t){return arguments.length?(n=t,r):n},r.copy=function(){return t(e).unknown(n)},e=arguments.length?Array.from(e,Uh):[0,1],Vh(r)})),ap(Td,(function t(){var e=Yh();return e.copy=function(){return Wh(e,t())},Bc.apply(e,arguments),Vh(e)}),Vd),ap(Bd,(function t(){const e=nd(Hh()).domain([1,10]);return e.copy=()=>Wh(e,t()).base(e.base()),Bc.apply(e,arguments),e}),[Vd,Bd]),ap(zd,cd,Vd),ap(Nd,(function(){return cd.apply(null,arguments).exponent(.5)}),Vd),ap(Od,(function t(){var e=od(Hh());return e.copy=function(){return Wh(e,t()).constant(e.constant())},Bc.apply(e,arguments)}),Vd),ap(Rd,(function(){return Bc.apply(dd(qn,Pn,Nn,Bn,vn,pn,hn,cn,ln,ni).domain([new Date(2e3,0,1),new Date(2e3,0,2)]),arguments)}),[Vd,Qd]),ap(Ud,(function(){return Bc.apply(dd(Un,Ln,On,zn,En,gn,dn,fn,ln,ii).domain([Date.UTC(2e3,0,1),Date.UTC(2e3,0,2)]),arguments)}),[Vd,Qd]),ap(Ld,md,[Vd,Zd]),ap(`${Ld}-${Td}`,md,[Vd,Zd]),ap(`${Ld}-${Bd}`,(function t(){var e=nd(pd()).domain([1,10]);return e.copy=function(){return gd(e,t()).base(e.base())},zc.apply(e,arguments)}),[Vd,Zd,Bd]),ap(`${Ld}-${zd}`,yd,[Vd,Zd]),ap(`${Ld}-${Nd}`,(function(){return yd.apply(null,arguments).exponent(.5)}),[Vd,Zd]),ap(`${Ld}-${Od}`,(function t(){var e=od(pd());return e.copy=function(){return gd(e,t()).constant(e.constant())},zc.apply(e,arguments)}),[Vd,Zd]),ap(`${qd}-${Td}`,(function t(){var e=Vh(vd()(qh));return e.copy=function(){return gd(e,t())},zc.apply(e,arguments)}),[Vd,Zd]),ap(`${qd}-${Bd}`,(function t(){var e=nd(vd()).domain([.1,1,10]);return e.copy=function(){return gd(e,t()).base(e.base())},zc.apply(e,arguments)}),[Vd,Zd,Bd]),ap(`${qd}-${zd}`,_d,[Vd,Zd]),ap(`${qd}-${Nd}`,(function(){return _d.apply(null,arguments).exponent(.5)}),[Vd,Zd]),ap(`${qd}-${Od}`,(function t(){var e=od(vd());return e.copy=function(){return gd(e,t()).constant(e.constant())},zc.apply(e,arguments)}),[Vd,Zd]),ap(Pd,(function t(){var e,n=[],r=[],i=[];function o(){var t=0,e=Math.max(1,r.length);for(i=new Array(e-1);++t0?i[e-1]:n[0],e=i?[o[i-1],r]:[o[e-1],o[e]]},s.unknown=function(t){return arguments.length?(e=t,s):s},s.thresholds=function(){return o.slice()},s.copy=function(){return t().domain([n,r]).range(a).unknown(e)},Bc.apply(Vh(s),arguments)}),Jd),ap(Id,(function t(){var e,n=[.5],r=[0,1],i=1;function o(t){return null!=t&&t<=t?r[oe(n,t,0,i)]:e}return o.domain=function(t){return arguments.length?(n=Array.from(t),i=Math.min(n.length,r.length-1),o):n.slice()},o.range=function(t){return arguments.length?(r=Array.from(t),i=Math.min(n.length,r.length-1),o):r.slice()},o.invertExtent=function(t){var e=r.indexOf(t);return[n[e-1],n[e]]},o.unknown=function(t){return arguments.length?(e=t,o):e},o.copy=function(){return t().domain(n).range(r).unknown(e)},Bc.apply(o,arguments)}),Jd),ap(Gd,(function t(){let e=[],n=[];function r(t){return null==t||t!=t?void 0:n[(oe(e,t)-1)%n.length]}return r.domain=function(t){return arguments.length?(e=function(t){return ep.call(t,S)}(t),r):e.slice()},r.range=function(t){return arguments.length?(n=np.call(t),r):n.slice()},r.tickFormat=function(t,n){return Gh(e[0],F(e),null==t?10:t,n)},r.copy=function(){return t().domain(r.domain()).range(r.range())},r}),[Xd,Jd]),ap(Wd,Oc,Xd),ap(Yd,Kd,Xd),ap(Hd,(function(){return tp(Kd().paddingInner(1))}),Xd);const gp=["clamp","base","constant","exponent"];function mp(t,e){const n=e[0],r=F(e)-n;return function(e){return t(n+e*r)}}function yp(t,e,n){return Oh(xp(e||"rgb",n),t)}function vp(t,e){const n=new Array(e),r=e+1;for(let i=0;it[e]?a[e](t[e]()):0)),a):rt(.5)}function xp(t,e){const n=Rh[function(t){return"interpolate"+t.toLowerCase().split("-").map((t=>t[0].toUpperCase()+t.slice(1))).join("")}(t)];return null!=e&&n&&n.gamma?n.gamma(e):n}function bp(t){if(k(t))return t;const e=t.length/6|0,n=new Array(e);for(let r=0;r1?(kp[t]=e,this):kp[t]}wp({accent:wd,category10:bd,category20:"1f77b4aec7e8ff7f0effbb782ca02c98df8ad62728ff98969467bdc5b0d58c564bc49c94e377c2f7b6d27f7f7fc7c7c7bcbd22dbdb8d17becf9edae5",category20b:"393b795254a36b6ecf9c9ede6379398ca252b5cf6bcedb9c8c6d31bd9e39e7ba52e7cb94843c39ad494ad6616be7969c7b4173a55194ce6dbdde9ed6",category20c:"3182bd6baed69ecae1c6dbefe6550dfd8d3cfdae6bfdd0a231a35474c476a1d99bc7e9c0756bb19e9ac8bcbddcdadaeb636363969696bdbdbdd9d9d9",dark2:kd,observable10:Ad,paired:Md,pastel1:Ed,pastel2:Dd,set1:Cd,set2:Fd,set3:Sd,tableau10:"4c78a8f58518e4575672b7b254a24beeca3bb279a2ff9da69d755dbab0ac",tableau20:"4c78a89ecae9f58518ffbf7954a24b88d27ab79a20f2cf5b43989483bcb6e45756ff9d9879706ebab0acd67195fcbfd2b279a2d6a5c99e765fd8b5a5"},bp),wp({blues:"cfe1f2bed8eca8cee58fc1de74b2d75ba3cf4592c63181bd206fb2125ca40a4a90",greens:"d3eecdc0e6baabdda594d3917bc77d60ba6c46ab5e329a512089430e7735036429",greys:"e2e2e2d4d4d4c4c4c4b1b1b19d9d9d8888887575756262624d4d4d3535351e1e1e",oranges:"fdd8b3fdc998fdb87bfda55efc9244f87f2cf06b18e4580bd14904b93d029f3303",purples:"e2e1efd4d4e8c4c5e0b4b3d6a3a0cc928ec3827cb97566ae684ea25c3696501f8c",reds:"fdc9b4fcb49afc9e80fc8767fa7051f6573fec3f2fdc2a25c81b1db21218970b13",blueGreen:"d5efedc1e8e0a7ddd18bd2be70c6a958ba9144ad77319c5d2089460e7736036429",bluePurple:"ccddecbad0e4a8c2dd9ab0d4919cc98d85be8b6db28a55a6873c99822287730f71",greenBlue:"d3eecec5e8c3b1e1bb9bd8bb82cec269c2ca51b2cd3c9fc7288abd1675b10b60a1",orangeRed:"fddcaffdcf9bfdc18afdad77fb9562f67d53ee6545e24932d32d1ebf130da70403",purpleBlue:"dbdaebc8cee4b1c3de97b7d87bacd15b9fc93a90c01e7fb70b70ab056199045281",purpleBlueGreen:"dbd8eac8cee4b0c3de93b7d872acd1549fc83892bb1c88a3097f8702736b016353",purpleRed:"dcc9e2d3b3d7ce9eccd186c0da6bb2e14da0e23189d91e6fc61159ab07498f023a",redPurple:"fccfccfcbec0faa9b8f98faff571a5ec539ddb3695c41b8aa908808d0179700174",yellowGreen:"e4f4acd1eca0b9e2949ed68880c97c62bb6e47aa5e3297502083440e723b036034",yellowOrangeBrown:"feeaa1fedd84fecc63feb746fca031f68921eb7215db5e0bc54c05ab3d038f3204",yellowOrangeRed:"fee087fed16ffebd59fea849fd903efc7335f9522bee3423de1b20ca0b22af0225",blueOrange:"134b852f78b35da2cb9dcae1d2e5eff2f0ebfce0bafbbf74e8932fc5690d994a07",brownBlueGreen:"704108a0651ac79548e3c78af3e6c6eef1eac9e9e48ed1c74da79e187a72025147",purpleGreen:"5b1667834792a67fb6c9aed3e6d6e8eff0efd9efd5aedda971bb75368e490e5e29",purpleOrange:"4114696647968f83b7b9b4d6dadbebf3eeeafce0bafbbf74e8932fc5690d994a07",redBlue:"8c0d25bf363adf745ef4ae91fbdbc9f2efeed2e5ef9dcae15da2cb2f78b3134b85",redGrey:"8c0d25bf363adf745ef4ae91fcdccbfaf4f1e2e2e2c0c0c0969696646464343434",yellowGreenBlue:"eff9bddbf1b4bde5b594d5b969c5be45b4c22c9ec02182b82163aa23479c1c3185",redYellowBlue:"a50026d4322cf16e43fcac64fedd90faf8c1dcf1ecabd6e875abd04a74b4313695",redYellowGreen:"a50026d4322cf16e43fcac63fedd8df9f7aed7ee8ea4d86e64bc6122964f006837",pinkYellowGreen:"8e0152c0267edd72adf0b3d6faddedf5f3efe1f2cab6de8780bb474f9125276419",spectral:"9e0142d13c4bf0704afcac63fedd8dfbf8b0e0f3a1a9dda269bda94288b55e4fa2",viridis:"440154470e61481a6c482575472f7d443a834144873d4e8a39568c35608d31688e2d708e2a788e27818e23888e21918d1f988b1fa08822a8842ab07f35b77943bf7154c56866cc5d7ad1518fd744a5db36bcdf27d2e21be9e51afde725",magma:"0000040404130b0924150e3720114b2c11603b0f704a107957157e651a80721f817f24828c29819a2e80a8327db6377ac43c75d1426fde4968e95462f1605df76f5cfa7f5efc8f65fe9f6dfeaf78febf84fece91fddea0fcedaffcfdbf",inferno:"0000040403130c0826170c3b240c4f330a5f420a68500d6c5d126e6b176e781c6d86216b932667a12b62ae305cbb3755c73e4cd24644dd513ae65c30ed6925f3771af8850ffb9506fca50afcb519fac62df6d645f2e661f3f484fcffa4",plasma:"0d088723069033059742039d5002a25d01a66a00a87801a88405a7900da49c179ea72198b12a90ba3488c33d80cb4779d35171da5a69e16462e76e5bed7953f2834cf68f44fa9a3dfca636fdb32ffec029fcce25f9dc24f5ea27f0f921",cividis:"00205100235800265d002961012b65042e670831690d346b11366c16396d1c3c6e213f6e26426e2c456e31476e374a6e3c4d6e42506e47536d4c566d51586e555b6e5a5e6e5e616e62646f66676f6a6a706e6d717270717573727976737c79747f7c75827f758682768985778c8877908b78938e789691789a94789e9778a19b78a59e77a9a177aea575b2a874b6ab73bbaf71c0b26fc5b66dc9b96acebd68d3c065d8c462ddc85fe2cb5ce7cf58ebd355f0d652f3da4ff7de4cfae249fce647",rainbow:"6e40aa883eb1a43db3bf3cafd83fa4ee4395fe4b83ff576eff6659ff7847ff8c38f3a130e2b72fcfcc36bee044aff05b8ff4576ff65b52f6673af27828ea8d1ddfa319d0b81cbecb23abd82f96e03d82e14c6edb5a5dd0664dbf6e40aa",sinebow:"ff4040fc582af47218e78d0bd5a703bfbf00a7d5038de70b72f41858fc2a40ff402afc5818f4720be78d03d5a700bfbf03a7d50b8de71872f42a58fc4040ff582afc7218f48d0be7a703d5bf00bfd503a7e70b8df41872fc2a58ff4040",turbo:"23171b32204a3e2a71453493493eae4b49c54a53d7485ee44569ee4074f53c7ff8378af93295f72e9ff42ba9ef28b3e926bce125c5d925cdcf27d5c629dcbc2de3b232e9a738ee9d3ff39347f68950f9805afc7765fd6e70fe667cfd5e88fc5795fb51a1f84badf545b9f140c5ec3cd0e637dae034e4d931ecd12ef4c92bfac029ffb626ffad24ffa223ff9821ff8d1fff821dff771cfd6c1af76118f05616e84b14df4111d5380fcb2f0dc0260ab61f07ac1805a313029b0f00950c00910b00",browns:"eedbbdecca96e9b97ae4a865dc9856d18954c7784cc0673fb85536ad44339f3632",tealBlues:"bce4d89dd3d181c3cb65b3c245a2b9368fae347da0306a932c5985",teals:"bbdfdfa2d4d58ac9c975bcbb61b0af4da5a43799982b8b8c1e7f7f127273006667",warmGreys:"dcd4d0cec5c1c0b8b4b3aaa7a59c9998908c8b827f7e7673726866665c5a59504e",goldGreen:"f4d166d5ca60b6c35c98bb597cb25760a6564b9c533f8f4f33834a257740146c36",goldOrange:"f4d166f8be5cf8aa4cf5983bf3852aef701be2621fd65322c54923b142239e3a26",goldRed:"f4d166f6be59f9aa51fc964ef6834bee734ae56249db5247cf4244c43141b71d3e",lightGreyRed:"efe9e6e1dad7d5cbc8c8bdb9bbaea9cd967ddc7b43e15f19df4011dc000b",lightGreyTeal:"e4eaead6dcddc8ced2b7c2c7a6b4bc64b0bf22a6c32295c11f85be1876bc",lightMulti:"e0f1f2c4e9d0b0de9fd0e181f6e072f6c053f3993ef77440ef4a3c",lightOrange:"f2e7daf7d5baf9c499fab184fa9c73f68967ef7860e8645bde515bd43d5b",lightTealBlue:"e3e9e0c0dccf9aceca7abfc859afc0389fb9328dad2f7ca0276b95255988",darkBlue:"3232322d46681a5c930074af008cbf05a7ce25c0dd38daed50f3faffffff",darkGold:"3c3c3c584b37725e348c7631ae8b2bcfa424ecc31ef9de30fff184ffffff",darkGreen:"3a3a3a215748006f4d048942489e4276b340a6c63dd2d836ffeb2cffffaa",darkMulti:"3737371f5287197d8c29a86995ce3fffe800ffffff",darkRed:"3434347036339e3c38cc4037e75d1eec8620eeab29f0ce32ffeb2c"},(t=>yp(bp(t))));const Mp="symbol",Ep="discrete",Dp=t=>k(t)?t.map((t=>String(t))):String(t),Cp=(t,e)=>t[1]-e[1],Fp=(t,e)=>e[1]-t[1];function Sp(t,e,n){let r;return vt(e)&&(t.bins&&(e=Math.max(e,t.bins.length)),null!=n&&(e=Math.min(e,Math.floor(Dt(t.domain())/n||1)+1))),A(e)&&(r=e.step,e=e.interval),xt(e)&&(e=t.type===Rd?Cr(e):t.type==Ud?Fr(e):s("Only time and utc scales accept interval strings."),r&&(e=e.every(r))),e}function $p(t,e,n){let r=t.range(),i=r[0],o=F(r),a=Cp;if(i>o&&(r=o,o=i,i=r,a=Fp),i=Math.floor(i),o=Math.ceil(o),e=e.map((e=>[e,t(e)])).filter((t=>i<=t[1]&&t[1]<=o)).sort(a).map((t=>t[0])),n>0&&e.length>1){const t=[e[0],F(e)];for(;e.length>n&&e.length>=3;)e=e.filter(((t,e)=>!(e%2)));e.length<3&&(e=t)}return e}function Tp(t,e){return t.bins?$p(t,t.bins,e):t.ticks?t.ticks(e):t.domain()}function Bp(t,e,n,r,i,o){const a=e.type;let s=Dp;if(a===Rd||i===Rd)s=t.timeFormat(r);else if(a===Ud||i===Ud)s=t.utcFormat(r);else if(hp(a)){const i=t.formatFloat(r);if(o||e.bins)s=i;else{const t=zp(e,n,!1);s=e=>t(e)?i(e):""}}else if(e.tickFormat){const i=e.domain();s=t.formatSpan(i[0],i[i.length-1],n,r)}else r&&(s=t.format(r));return s}function zp(t,e,n){const r=Tp(t,e),i=t.base(),o=Math.log(i),a=Math.max(1,i*e/r.length),s=t=>{let e=t/Math.pow(i,Math.round(Math.log(t)/o));return e*iNp[t.type]||t.bins;function Lp(t,e,n,r,i,o,a){const s=Op[e.type]&&o!==Rd&&o!==Ud?function(t,e,n){const r=e[Op[e.type]](),i=r.length;let o,a=i>1?r[1]-r[0]:r[0];for(o=1;o(e,n,r)=>{const i=Pp(r[n+1],Pp(r.max,1/0)),o=Wp(e,t),a=Wp(i,t);return o&&a?o+" – "+a:a?"< "+a:"≥ "+o},Pp=(t,e)=>null!=t?t:e,jp=t=>(e,n)=>n?t(e):null,Ip=t=>e=>t(e),Wp=(t,e)=>Number.isFinite(t)?e(t):null;function Hp(t,e,n,r){const i=r||e.type;return xt(n)&&function(t){return up(t,Qd)}(i)&&(n=n.replace(/%a/g,"%A").replace(/%b/g,"%B")),n||i!==Rd?n||i!==Ud?Lp(t,e,5,null,n,r,!0):t.utcFormat("%A, %d %B %Y, %X UTC"):t.timeFormat("%A, %d %B %Y, %X")}function Yp(t,e,n){n=n||{};const r=Math.max(3,n.maxlen||7),i=Hp(t,e,n.format,n.formatType);if(fp(e.type)){const t=Rp(e).slice(1).map(i),n=t.length;return`${n} boundar${1===n?"y":"ies"}: ${t.join(", ")}`}if(cp(e.type)){const t=e.domain(),n=t.length;return`${n} value${1===n?"":"s"}: ${n>r?t.slice(0,r-2).map(i).join(", ")+", ending with "+t.slice(-1).map(i):t.map(i).join(", ")}`}{const t=e.domain();return`values from ${i(t[0])} to ${i(F(t))}`}}let Gp=0;const Vp="p_";function Xp(t){return t&&t.gradient}function Jp(t,e,n){const r=t.gradient;let i=t.id,o="radial"===r?Vp:"";return i||(i=t.id="gradient_"+Gp++,"radial"===r?(t.x1=Zp(t.x1,.5),t.y1=Zp(t.y1,.5),t.r1=Zp(t.r1,0),t.x2=Zp(t.x2,.5),t.y2=Zp(t.y2,.5),t.r2=Zp(t.r2,.5),o=Vp):(t.x1=Zp(t.x1,0),t.y1=Zp(t.y1,0),t.x2=Zp(t.x2,1),t.y2=Zp(t.y2,0))),e[i]=t,"url("+(n||"")+"#"+o+i+")"}function Zp(t,e){return null!=t?t:e}function Qp(t,e){var n,r=[];return n={gradient:"linear",x1:t?t[0]:0,y1:t?t[1]:0,x2:e?e[0]:1,y2:e?e[1]:0,stops:r,stop:function(t,e){return r.push({offset:t,color:e}),n}}}const Kp={basis:{curve:function(t){return new ec(t)}},"basis-closed":{curve:function(t){return new nc(t)}},"basis-open":{curve:function(t){return new rc(t)}},bundle:{curve:oc,tension:"beta",value:.85},cardinal:{curve:uc,tension:"tension",value:0},"cardinal-open":{curve:hc,tension:"tension",value:0},"cardinal-closed":{curve:cc,tension:"tension",value:0},"catmull-rom":{curve:gc,tension:"alpha",value:.5},"catmull-rom-closed":{curve:yc,tension:"alpha",value:.5},"catmull-rom-open":{curve:_c,tension:"alpha",value:.5},linear:{curve:Gl},"linear-closed":{curve:function(t){return new xc(t)}},monotone:{horizontal:function(t){return new Ec(t)},vertical:function(t){return new Mc(t)}},natural:{curve:function(t){return new Cc(t)}},step:{curve:function(t){return new Sc(t,.5)}},"step-after":{curve:function(t){return new Sc(t,1)}},"step-before":{curve:function(t){return new Sc(t,0)}}};function tg(t,e,n){var r=lt(Kp,t)&&Kp[t],i=null;return r&&(i=r.curve||r[e||"vertical"],r.tension&&null!=n&&(i=i[r.tension](n))),i}const eg={m:2,l:2,h:1,v:1,z:0,c:6,s:4,q:4,t:2,a:7},ng=/[mlhvzcsqta]([^mlhvzcsqta]+|$)/gi,rg=/^[+-]?(([0-9]*\.[0-9]+)|([0-9]+\.)|([0-9]+))([eE][+-]?[0-9]+)?/,ig=/^((\s+,?\s*)|(,\s*))/,og=/^[01]/;function ag(t){const e=[];return(t.match(ng)||[]).forEach((t=>{let n=t[0];const r=n.toLowerCase(),i=eg[r],o=function(t,e,n){const r=[];for(let i=0;e&&i1&&(g=Math.sqrt(g),n*=g,r*=g);const m=h/n,y=f/n,v=-f/r,_=h/r,x=m*s+y*u,b=v*s+_*u,w=m*t+y*e,k=v*t+_*e;let A=1/((w-x)*(w-x)+(k-b)*(k-b))-.25;A<0&&(A=0);let M=Math.sqrt(A);o==i&&(M=-M);const E=.5*(x+w)-M*(k-b),D=.5*(b+k)+M*(w-x),C=Math.atan2(b-D,x-E);let F=Math.atan2(k-D,w-E)-C;F<0&&1===o?F+=lg:F>0&&0===o&&(F-=lg);const S=Math.ceil(Math.abs(F/(ug+.001))),$=[];for(let t=0;t+t}function Fg(t,e,n){return Math.max(e,Math.min(t,n))}function Sg(){var t=Ag,e=Mg,n=Eg,r=Dg,i=Cg(0),o=i,a=i,s=i,u=null;function l(l,c,f){var h,d=null!=c?c:+t.call(this,l),p=null!=f?f:+e.call(this,l),g=+n.call(this,l),m=+r.call(this,l),y=Math.min(g,m)/2,v=Fg(+i.call(this,l),0,y),_=Fg(+o.call(this,l),0,y),x=Fg(+a.call(this,l),0,y),b=Fg(+s.call(this,l),0,y);if(u||(u=h=Rl()),v<=0&&_<=0&&x<=0&&b<=0)u.rect(d,p,g,m);else{var w=d+g,k=p+m;u.moveTo(d+v,p),u.lineTo(w-_,p),u.bezierCurveTo(w-kg*_,p,w,p+kg*_,w,p+_),u.lineTo(w,k-b),u.bezierCurveTo(w,k-kg*b,w-kg*b,k,w-b,k),u.lineTo(d+x,k),u.bezierCurveTo(d+kg*x,k,d,k-kg*x,d,k-x),u.lineTo(d,p+v),u.bezierCurveTo(d,p+kg*v,d+kg*v,p,d+v,p),u.closePath()}if(h)return u=null,h+""||null}return l.x=function(e){return arguments.length?(t=Cg(e),l):t},l.y=function(t){return arguments.length?(e=Cg(t),l):e},l.width=function(t){return arguments.length?(n=Cg(t),l):n},l.height=function(t){return arguments.length?(r=Cg(t),l):r},l.cornerRadius=function(t,e,n,r){return arguments.length?(i=Cg(t),o=null!=e?Cg(e):i,s=null!=n?Cg(n):i,a=null!=r?Cg(r):o,l):i},l.context=function(t){return arguments.length?(u=null==t?null:t,l):u},l}function $g(){var t,e,n,r,i,o,a,s,u=null;function l(t,e,n){const r=n/2;if(i){var l=a-e,c=t-o;if(l||c){var f=Math.hypot(l,c),h=(l/=f)*s,d=(c/=f)*s,p=Math.atan2(c,l);u.moveTo(o-h,a-d),u.lineTo(t-l*r,e-c*r),u.arc(t,e,r,p-Math.PI,p),u.lineTo(o+h,a+d),u.arc(o,a,s,p,p+Math.PI)}else u.arc(t,e,r,0,lg);u.closePath()}else i=1;o=t,a=e,s=r}function c(o){var a,s,c,f=o.length,h=!1;for(null==u&&(u=c=Rl()),a=0;a<=f;++a)!(at.x||0,zg=t=>t.y||0,Ng=t=>!(!1===t.defined),Og=function(){var t=Ll,e=ql,n=vl(0),r=null,i=Pl,o=jl,a=Il,s=null,u=Ul(l);function l(){var l,c,f=+t.apply(this,arguments),h=+e.apply(this,arguments),d=i.apply(this,arguments)-Cl,p=o.apply(this,arguments)-Cl,g=_l(p-d),m=p>d;if(s||(s=l=u()),hEl)if(g>Fl-El)s.moveTo(h*bl(d),h*Al(d)),s.arc(0,0,h,d,p,!m),f>El&&(s.moveTo(f*bl(p),f*Al(p)),s.arc(0,0,f,p,d,m));else{var y,v,_=d,x=p,b=d,w=p,k=g,A=g,M=a.apply(this,arguments)/2,E=M>El&&(r?+r.apply(this,arguments):Ml(f*f+h*h)),D=kl(_l(h-f)/2,+n.apply(this,arguments)),C=D,F=D;if(E>El){var S=Sl(E/f*Al(M)),$=Sl(E/h*Al(M));(k-=2*S)>El?(b+=S*=m?1:-1,w-=S):(k=0,b=w=(d+p)/2),(A-=2*$)>El?(_+=$*=m?1:-1,x-=$):(A=0,_=x=(d+p)/2)}var T=h*bl(_),B=h*Al(_),z=f*bl(w),N=f*Al(w);if(D>El){var O,R=h*bl(x),U=h*Al(x),L=f*bl(b),q=f*Al(b);if(g1?0:t<-1?Dl:Math.acos(t)}((P*I+j*W)/(Ml(P*P+j*j)*Ml(I*I+W*W)))/2),Y=Ml(O[0]*O[0]+O[1]*O[1]);C=kl(D,(f-Y)/(H-1)),F=kl(D,(h-Y)/(H+1))}else C=F=0}A>El?F>El?(y=Wl(L,q,T,B,h,F,m),v=Wl(R,U,z,N,h,F,m),s.moveTo(y.cx+y.x01,y.cy+y.y01),FEl&&k>El?C>El?(y=Wl(z,N,R,U,f,-C,m),v=Wl(T,B,L,q,f,-C,m),s.lineTo(y.cx+y.x01,y.cy+y.y01),Ct.startAngle||0)).endAngle((t=>t.endAngle||0)).padAngle((t=>t.padAngle||0)).innerRadius((t=>t.innerRadius||0)).outerRadius((t=>t.outerRadius||0)).cornerRadius((t=>t.cornerRadius||0)),Rg=Zl().x(Bg).y1(zg).y0((t=>(t.y||0)+(t.height||0))).defined(Ng),Ug=Zl().y(zg).x1(Bg).x0((t=>(t.x||0)+(t.width||0))).defined(Ng),Lg=Jl().x(Bg).y(zg).defined(Ng),qg=Sg().x(Bg).y(zg).width((t=>t.width||0)).height((t=>t.height||0)).cornerRadius((t=>Tg(t.cornerRadiusTopLeft,t.cornerRadius)||0),(t=>Tg(t.cornerRadiusTopRight,t.cornerRadius)||0),(t=>Tg(t.cornerRadiusBottomRight,t.cornerRadius)||0),(t=>Tg(t.cornerRadiusBottomLeft,t.cornerRadius)||0)),Pg=function(t,e){let n=null,r=Ul(i);function i(){let i;if(n||(n=i=r()),t.apply(this,arguments).draw(n,+e.apply(this,arguments)),i)return n=null,i+""||null}return t="function"==typeof t?t:vl(t||Ql),e="function"==typeof e?e:vl(void 0===e?64:+e),i.type=function(e){return arguments.length?(t="function"==typeof e?e:vl(e),i):t},i.size=function(t){return arguments.length?(e="function"==typeof t?t:vl(+t),i):e},i.context=function(t){return arguments.length?(n=null==t?null:t,i):n},i}().type((t=>bg(t.shape||"circle"))).size((t=>Tg(t.size,64))),jg=$g().x(Bg).y(zg).defined(Ng).size((t=>t.size||1));function Ig(t){return t.cornerRadius||t.cornerRadiusTopLeft||t.cornerRadiusTopRight||t.cornerRadiusBottomRight||t.cornerRadiusBottomLeft}function Wg(t,e,n,r){return qg.context(t)(e,n,r)}var Hg=1;function Yg(){Hg=1}function Gg(t,e,n){var r=e.clip,i=t._defs,o=e.clip_id||(e.clip_id="clip"+Hg++),a=i.clipping[o]||(i.clipping[o]={id:o});return J(r)?a.path=r(null):Ig(n)?a.path=Wg(null,n,0,0):(a.width=n.width||0,a.height=n.height||0),"url(#"+o+")"}function Vg(t){this.clear(),t&&this.union(t)}function Xg(t){this.mark=t,this.bounds=this.bounds||new Vg}function Jg(t){Xg.call(this,t),this.items=this.items||[]}Vg.prototype={clone(){return new Vg(this)},clear(){return this.x1=+Number.MAX_VALUE,this.y1=+Number.MAX_VALUE,this.x2=-Number.MAX_VALUE,this.y2=-Number.MAX_VALUE,this},empty(){return this.x1===+Number.MAX_VALUE&&this.y1===+Number.MAX_VALUE&&this.x2===-Number.MAX_VALUE&&this.y2===-Number.MAX_VALUE},equals(t){return this.x1===t.x1&&this.y1===t.y1&&this.x2===t.x2&&this.y2===t.y2},set(t,e,n,r){return nthis.x2&&(this.x2=t),e>this.y2&&(this.y2=e),this},expand(t){return this.x1-=t,this.y1-=t,this.x2+=t,this.y2+=t,this},round(){return this.x1=Math.floor(this.x1),this.y1=Math.floor(this.y1),this.x2=Math.ceil(this.x2),this.y2=Math.ceil(this.y2),this},scale(t){return this.x1*=t,this.y1*=t,this.x2*=t,this.y2*=t,this},translate(t,e){return this.x1+=t,this.x2+=t,this.y1+=e,this.y2+=e,this},rotate(t,e,n){const r=this.rotatedPoints(t,e,n);return this.clear().add(r[0],r[1]).add(r[2],r[3]).add(r[4],r[5]).add(r[6],r[7])},rotatedPoints(t,e,n){var{x1:r,y1:i,x2:o,y2:a}=this,s=Math.cos(t),u=Math.sin(t),l=e-e*s+n*u,c=n-e*u-n*s;return[s*r-u*i+l,u*r+s*i+c,s*r-u*a+l,u*r+s*a+c,s*o-u*i+l,u*o+s*i+c,s*o-u*a+l,u*o+s*a+c]},union(t){return t.x1this.x2&&(this.x2=t.x2),t.y2>this.y2&&(this.y2=t.y2),this},intersect(t){return t.x1>this.x1&&(this.x1=t.x1),t.y1>this.y1&&(this.y1=t.y1),t.x2=t.x2&&this.y1<=t.y1&&this.y2>=t.y2},alignsWith(t){return t&&(this.x1==t.x1||this.x2==t.x2||this.y1==t.y1||this.y2==t.y2)},intersects(t){return t&&!(this.x2t.x2||this.y2t.y2)},contains(t,e){return!(tthis.x2||ethis.y2)},width(){return this.x2-this.x1},height(){return this.y2-this.y1}},dt(Jg,Xg);class Zg{constructor(t){this._pending=0,this._loader=t||fa()}pending(){return this._pending}sanitizeURL(t){const e=this;return Qg(e),e._loader.sanitize(t,{context:"href"}).then((t=>(Kg(e),t))).catch((()=>(Kg(e),null)))}loadImage(t){const e=this,n=Tc();return Qg(e),e._loader.sanitize(t,{context:"image"}).then((t=>{const r=t.href;if(!r||!n)throw{url:r};const i=new n,o=lt(t,"crossOrigin")?t.crossOrigin:"anonymous";return null!=o&&(i.crossOrigin=o),i.onload=()=>Kg(e),i.onerror=()=>Kg(e),i.src=r,i})).catch((t=>(Kg(e),{complete:!1,width:0,height:0,src:t&&t.url||""})))}ready(){const t=this;return new Promise((e=>{!function n(r){t.pending()?setTimeout((()=>{n(!0)}),10):e(r)}(!1)}))}}function Qg(t){t._pending+=1}function Kg(t){t._pending-=1}function tm(t,e,n){if(e.stroke&&0!==e.opacity&&0!==e.strokeOpacity){const r=null!=e.strokeWidth?+e.strokeWidth:1;t.expand(r+(n?function(t,e){return t.strokeJoin&&"miter"!==t.strokeJoin?0:e}(e,r):0))}return t}const em=lg-1e-8;let nm,rm,im,om,am,sm,um,lm;const cm=(t,e)=>nm.add(t,e),fm=(t,e)=>cm(rm=t,im=e),hm=t=>cm(t,nm.y1),dm=t=>cm(nm.x1,t),pm=(t,e)=>am*t+um*e,gm=(t,e)=>sm*t+lm*e,mm=(t,e)=>cm(pm(t,e),gm(t,e)),ym=(t,e)=>fm(pm(t,e),gm(t,e));function vm(t,e){return nm=t,e?(om=e*sg,am=lm=Math.cos(om),sm=Math.sin(om),um=-sm):(am=lm=1,om=sm=um=0),_m}const _m={beginPath(){},closePath(){},moveTo:ym,lineTo:ym,rect(t,e,n,r){om?(mm(t+n,e),mm(t+n,e+r),mm(t,e+r),ym(t,e)):(cm(t+n,e+r),fm(t,e))},quadraticCurveTo(t,e,n,r){const i=pm(t,e),o=gm(t,e),a=pm(n,r),s=gm(n,r);xm(rm,i,a,hm),xm(im,o,s,dm),fm(a,s)},bezierCurveTo(t,e,n,r,i,o){const a=pm(t,e),s=gm(t,e),u=pm(n,r),l=gm(n,r),c=pm(i,o),f=gm(i,o);bm(rm,a,u,c,hm),bm(im,s,l,f,dm),fm(c,f)},arc(t,e,n,r,i,o){if(r+=om,i+=om,rm=n*Math.cos(i)+t,im=n*Math.sin(i)+e,Math.abs(i-r)>em)cm(t-n,e-n),cm(t+n,e+n);else{const a=r=>cm(n*Math.cos(r)+t,n*Math.sin(r)+e);let s,u;if(a(r),a(i),i!==r)if((r%=lg)<0&&(r+=lg),(i%=lg)<0&&(i+=lg),ii;++u,s-=ug)a(s);else for(s=r-r%ug+ug,u=0;u<4&&s1e-14?(u=a*a+s*o,u>=0&&(u=Math.sqrt(u),l=(-a+u)/o,c=(-a-u)/o)):l=.5*s/a,0m)return!1;d>g&&(g=d)}else if(f>0){if(d0&&(t.globalAlpha=n,t.fillStyle=Bm(t,e,e.fill),!0)}var Nm=[];function Om(t,e,n){var r=null!=(r=e.strokeWidth)?r:1;return!(r<=0)&&((n*=null==e.strokeOpacity?1:e.strokeOpacity)>0&&(t.globalAlpha=n,t.strokeStyle=Bm(t,e,e.stroke),t.lineWidth=r,t.lineCap=e.strokeCap||"butt",t.lineJoin=e.strokeJoin||"miter",t.miterLimit=e.strokeMiterLimit||10,t.setLineDash&&(t.setLineDash(e.strokeDash||Nm),t.lineDashOffset=e.strokeDashOffset||0),!0))}function Rm(t,e){return t.zindex-e.zindex||t.index-e.index}function Um(t){if(!t.zdirty)return t.zitems;var e,n,r,i=t.items,o=[];for(n=0,r=i.length;n=0;)if(n=e(i[r]))return n;if(i===o)for(r=(i=t.items).length;--r>=0;)if(!i[r].zindex&&(n=e(i[r])))return n;return null}function Pm(t){return function(e,n,r){Lm(n,(n=>{r&&!r.intersects(n.bounds)||Im(t,e,n,n)}))}}function jm(t){return function(e,n,r){!n.items.length||r&&!r.intersects(n.bounds)||Im(t,e,n.items[0],n.items)}}function Im(t,e,n,r){var i=null==n.opacity?1:n.opacity;0!==i&&(t(e,r)||(Sm(e,n),n.fill&&zm(e,n,i)&&e.fill(),n.stroke&&Om(e,n,i)&&e.stroke()))}function Wm(t){return t=t||p,function(e,n,r,i,o,a){return r*=e.pixelRatio,i*=e.pixelRatio,qm(n,(n=>{const s=n.bounds;if((!s||s.contains(o,a))&&s)return t(e,n,r,i,o,a)?n:void 0}))}}function Hm(t,e){return function(n,r,i,o){var a,s,u=Array.isArray(r)?r[0]:r,l=null==e?u.fill:e,c=u.stroke&&n.isPointInStroke;return c&&(a=u.strokeWidth,s=u.strokeCap,n.lineWidth=null!=a?a:1,n.lineCap=null!=s?s:"butt"),!t(n,r)&&(l&&n.isPointInPath(i,o)||c&&n.isPointInStroke(i,o))}}function Ym(t){return Wm(Hm(t))}function Gm(t,e){return"translate("+t+","+e+")"}function Vm(t){return"rotate("+t+")"}function Xm(t){return Gm(t.x||0,t.y||0)}function Jm(t,e,n){function r(t,n){var r=n.x||0,i=n.y||0,o=n.angle||0;t.translate(r,i),o&&t.rotate(o*=sg),t.beginPath(),e(t,n),o&&t.rotate(-o),t.translate(-r,-i)}return{type:t,tag:"path",nested:!1,attr:function(t,n){t("transform",function(t){return Gm(t.x||0,t.y||0)+(t.angle?" "+Vm(t.angle):"")}(n)),t("d",e(null,n))},bound:function(t,n){return e(vm(t,n.angle),n),tm(t,n).translate(n.x||0,n.y||0)},draw:Pm(r),pick:Ym(r),isect:n||Mm(r)}}var Zm=Jm("arc",(function(t,e){return Og.context(t)(e)}));function Qm(t,e,n){function r(t,n){t.beginPath(),e(t,n)}const i=Hm(r);return{type:t,tag:"path",nested:!0,attr:function(t,n){var r=n.mark.items;r.length&&t("d",e(null,r))},bound:function(t,n){var r=n.items;return 0===r.length?t:(e(vm(t),r),tm(t,r[0]))},draw:jm(r),pick:function(t,e,n,r,o,a){var s=e.items,u=e.bounds;return!s||!s.length||u&&!u.contains(o,a)?null:(n*=t.pixelRatio,r*=t.pixelRatio,i(t,s,n,r)?s[0]:null)},isect:Em,tip:n}}var Km=Qm("area",(function(t,e){const n=e[0],r=n.interpolate||"linear";return("horizontal"===n.orient?Ug:Rg).curve(tg(r,n.orient,n.tension)).context(t)(e)}),(function(t,e){for(var n,r,i="horizontal"===t[0].orient?e[1]:e[0],o="horizontal"===t[0].orient?"y":"x",a=t.length,s=1/0;--a>=0;)!1!==t[a].defined&&(r=Math.abs(t[a][o]-i)).5&&e<1.5?.5-Math.abs(e-1):0}function ny(t,e){const n=ey(e);t("d",Wg(null,e,n,n))}function ry(t,e,n,r){const i=ey(e);t.beginPath(),Wg(t,e,(n||0)+i,(r||0)+i)}const iy=Hm(ry),oy=Hm(ry,!1),ay=Hm(ry,!0);var sy={type:"group",tag:"g",nested:!1,attr:function(t,e){t("transform",Xm(e))},bound:function(t,e){if(!e.clip&&e.items){const n=e.items,r=n.length;for(let e=0;e{const i=e.x||0,o=e.y||0,a=e.strokeForeground,s=null==e.opacity?1:e.opacity;(e.stroke||e.fill)&&s&&(ry(t,e,i,o),Sm(t,e),e.fill&&zm(t,e,s)&&t.fill(),e.stroke&&!a&&Om(t,e,s)&&t.stroke()),t.save(),t.translate(i,o),e.clip&&ty(t,e),n&&n.translate(-i,-o),Lm(e,(e=>{("group"===e.marktype||null==r||r.includes(e.marktype))&&this.draw(t,e,n,r)})),n&&n.translate(i,o),t.restore(),a&&e.stroke&&s&&(ry(t,e,i,o),Sm(t,e),Om(t,e,s)&&t.stroke())}))},pick:function(t,e,n,r,i,o){if(e.bounds&&!e.bounds.contains(i,o)||!e.items)return null;const a=n*t.pixelRatio,s=r*t.pixelRatio;return qm(e,(u=>{let l,c,f;const h=u.bounds;if(h&&!h.contains(i,o))return;c=u.x||0,f=u.y||0;const d=c+(u.width||0),p=f+(u.height||0),g=u.clip;if(g&&(id||op))return;if(t.save(),t.translate(c,f),c=i-c,f=o-f,g&&Ig(u)&&!ay(t,u,a,s))return t.restore(),null;const m=u.strokeForeground,y=!1!==e.interactive;return y&&m&&u.stroke&&oy(t,u,a,s)?(t.restore(),u):(l=qm(u,(t=>function(t,e,n){return(!1!==t.interactive||"group"===t.marktype)&&t.bounds&&t.bounds.contains(e,n)}(t,c,f)?this.pick(t,n,r,c,f):null)),!l&&y&&(u.fill||!m&&u.stroke)&&iy(t,u,a,s)&&(l=u),t.restore(),l||null)}))},isect:Dm,content:function(t,e,n){t("clip-path",e.clip?Gg(n,e,e):null)},background:function(t,e){t("class","background"),t("aria-hidden",!0),ny(t,e)},foreground:function(t,e){t("class","foreground"),t("aria-hidden",!0),e.strokeForeground?ny(t,e):t("d","")}},uy={xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",version:"1.1"};function ly(t,e){var n=t.image;return(!n||t.url&&t.url!==n.url)&&(n={complete:!1,width:0,height:0},e.loadImage(t.url).then((e=>{t.image=e,t.image.url=t.url}))),n}function cy(t,e){return null!=t.width?t.width:e&&e.width?!1!==t.aspect&&t.height?t.height*e.width/e.height:e.width:0}function fy(t,e){return null!=t.height?t.height:e&&e.height?!1!==t.aspect&&t.width?t.width*e.height/e.width:e.height:0}function hy(t,e){return"center"===t?e/2:"right"===t?e:0}function dy(t,e){return"middle"===t?e/2:"bottom"===t?e:0}var py={type:"image",tag:"image",nested:!1,attr:function(t,e,n){const r=ly(e,n),i=cy(e,r),o=fy(e,r),a=(e.x||0)-hy(e.align,i),s=(e.y||0)-dy(e.baseline,o);t("href",!r.src&&r.toDataURL?r.toDataURL():r.src||"",uy["xmlns:xlink"],"xlink:href"),t("transform",Gm(a,s)),t("width",i),t("height",o),t("preserveAspectRatio",!1===e.aspect?"none":"xMidYMid")},bound:function(t,e){const n=e.image,r=cy(e,n),i=fy(e,n),o=(e.x||0)-hy(e.align,r),a=(e.y||0)-dy(e.baseline,i);return t.set(o,a,o+r,a+i)},draw:function(t,e,n){Lm(e,(e=>{if(n&&!n.intersects(e.bounds))return;const r=ly(e,this);let i=cy(e,r),o=fy(e,r);if(0===i||0===o)return;let a,s,u,l,c=(e.x||0)-hy(e.align,i),f=(e.y||0)-dy(e.baseline,o);!1!==e.aspect&&(s=r.width/r.height,u=e.width/e.height,s==s&&u==u&&s!==u&&(u=0;)if(!1!==t[o].defined&&(n=t[o].x-e[0])*n+(r=t[o].y-e[1])*r{if(!n||n.intersects(e.bounds)){var r=null==e.opacity?1:e.opacity;r&&xy(t,e,r)&&(Sm(t,e),t.stroke())}}))},pick:Wm((function(t,e,n,r){return!!t.isPointInStroke&&(xy(t,e,1)&&t.isPointInStroke(n,r))})),isect:Cm},wy=Jm("shape",(function(t,e){return(e.mark.shape||e.shape).context(t)(e)})),ky=Jm("symbol",(function(t,e){return Pg.context(t)(e)}),Em);const Ay=kt();var My={height:$y,measureWidth:Fy,estimateWidth:Dy,width:Dy,canvas:Ey};function Ey(t){My.width=t&&km?Fy:Dy}function Dy(t,e){return Cy(Ny(t,e),$y(t))}function Cy(t,e){return~~(.8*t.length*e)}function Fy(t,e){return $y(t)<=0||!(e=Ny(t,e))?0:Sy(e,Ry(t))}function Sy(t,e){const n=`(${e}) ${t}`;let r=Ay.get(n);return void 0===r&&(km.font=e,r=km.measureText(t).width,Ay.set(n,r)),r}function $y(t){return null!=t.fontSize?+t.fontSize||0:11}function Ty(t){return null!=t.lineHeight?t.lineHeight:$y(t)+2}function By(t){return e=t.lineBreak&&t.text&&!k(t.text)?t.text.split(t.lineBreak):t.text,k(e)?e.length>1?e:e[0]:e;var e}function zy(t){const e=By(t);return(k(e)?e.length-1:0)*Ty(t)}function Ny(t,e){const n=null==e?"":(e+"").trim();return t.limit>0&&n.length?function(t,e){var n=+t.limit,r=function(t){if(My.width===Fy){const e=Ry(t);return t=>Sy(t,e)}if(My.width===Dy){const e=$y(t);return t=>Cy(t,e)}return e=>My.width(t,e)}(t);if(r(e)>>1,r(e.slice(i))>n?s=i+1:u=i;return o+e.slice(s)}for(;s>>1),r(e.slice(0,i))Math.max(t,My.width(e,n))),0)):r=My.width(e,f),"center"===o?l-=r/2:"right"===o&&(l-=r),t.set(l+=s,c+=u,l+r,c+i),e.angle&&!n)t.rotate(e.angle*sg,s,u);else if(2===n)return t.rotatedPoints(e.angle*sg,s,u);return t}var Iy={type:"text",tag:"text",nested:!1,attr:function(t,e){var n,r=e.dx||0,i=(e.dy||0)+Uy(e),o=Py(e),a=o.x1,s=o.y1,u=e.angle||0;t("text-anchor",Ly[e.align]||"start"),u?(n=Gm(a,s)+" "+Vm(u),(r||i)&&(n+=" "+Gm(r,i))):n=Gm(a+r,s+i),t("transform",n)},bound:jy,draw:function(t,e,n){Lm(e,(e=>{var r,i,o,a,s,u,l,c=null==e.opacity?1:e.opacity;if(!(n&&!n.intersects(e.bounds)||0===c||e.fontSize<=0||null==e.text||0===e.text.length)){if(t.font=Ry(e),t.textAlign=e.align||"left",i=(r=Py(e)).x1,o=r.y1,e.angle&&(t.save(),t.translate(i,o),t.rotate(e.angle*sg),i=o=0),i+=e.dx||0,o+=(e.dy||0)+Uy(e),u=By(e),Sm(t,e),k(u))for(s=Ty(e),a=0;a=0;)if(!1!==t[i].defined&&(n=t[i].x-e[0])*n+(r=t[i].y-e[1])*r<(n=t[i].size||1)*n)return t[i];return null})),Hy={arc:Zm,area:Km,group:sy,image:py,line:gy,path:yy,rect:_y,rule:by,shape:wy,symbol:ky,text:Iy,trail:Wy};function Yy(t,e,n){var r=Hy[t.mark.marktype],i=e||r.bound;return r.nested&&(t=t.mark),i(t.bounds||(t.bounds=new Vg),t,n)}var Gy={mark:null};function Vy(t,e,n){var r,i,o,a,s=Hy[t.marktype],u=s.bound,l=t.items,c=l&&l.length;if(s.nested)return c?o=l[0]:(Gy.mark=t,o=Gy),a=Yy(o,u,n),e=e&&e.union(a)||a;if(e=e||t.bounds&&t.bounds.clear()||new Vg,c)for(r=0,i=l.length;re;)t.removeChild(n[--r]);return t}function ov(t){return"mark-"+t.marktype+(t.role?" role-"+t.role:"")+(t.name?" "+t.name:"")}function av(t,e){const n=e.getBoundingClientRect();return[t.clientX-n.left-(e.clientLeft||0),t.clientY-n.top-(e.clientTop||0)]}class sv{constructor(t,e){this._active=null,this._handlers={},this._loader=t||fa(),this._tooltip=e||uv}initialize(t,e,n){return this._el=t,this._obj=n||null,this.origin(e)}element(){return this._el}canvas(){return this._el&&this._el.firstChild}origin(t){return arguments.length?(this._origin=t||[0,0],this):this._origin.slice()}scene(t){return arguments.length?(this._scene=t,this):this._scene}on(){}off(){}_handlerIndex(t,e,n){for(let r=t?t.length:0;--r>=0;)if(t[r].type===e&&(!n||t[r].handler===n))return r;return-1}handlers(t){const e=this._handlers,n=[];if(t)n.push(...e[this.eventName(t)]);else for(const t in e)n.push(...e[t]);return n}eventName(t){const e=t.indexOf(".");return e<0?t:t.slice(0,e)}handleHref(t,e,n){this._loader.sanitize(n,{context:"href"}).then((e=>{const n=new MouseEvent(t.type,t),r=ev(null,"a");for(const t in e)r.setAttribute(t,e[t]);r.dispatchEvent(n)})).catch((()=>{}))}handleTooltip(t,e,n){if(e&&null!=e.tooltip){e=function(t,e,n,r){var i,o,a=t&&t.mark;if(a&&(i=Hy[a.marktype]).tip){for((o=av(e,n))[0]-=r[0],o[1]-=r[1];t=t.mark.group;)o[0]-=t.x||0,o[1]-=t.y||0;t=i.tip(a.items,o)}return t}(e,t,this.canvas(),this._origin);const r=n&&e&&e.tooltip||null;this._tooltip.call(this._obj,this,t,e,r)}}getItemBoundingClientRect(t){const e=this.canvas();if(!e)return;const n=e.getBoundingClientRect(),r=this._origin,i=t.bounds,o=i.width(),a=i.height();let s=i.x1+r[0]+n.left,u=i.y1+r[1]+n.top;for(;t.mark&&(t=t.mark.group);)s+=t.x||0,u+=t.y||0;return{x:s,y:u,width:o,height:a,left:s,top:u,right:s+o,bottom:u+a}}}function uv(t,e,n,r){t.element().setAttribute("title",r||"")}class lv{constructor(t){this._el=null,this._bgcolor=null,this._loader=new Zg(t)}initialize(t,e,n,r,i){return this._el=t,this.resize(e,n,r,i)}element(){return this._el}canvas(){return this._el&&this._el.firstChild}background(t){return 0===arguments.length?this._bgcolor:(this._bgcolor=t,this)}resize(t,e,n,r){return this._width=t,this._height=e,this._origin=n||[0,0],this._scale=r||1,this}dirty(){}render(t,e){const n=this;return n._call=function(){n._render(t,e)},n._call(),n._call=null,n}_render(){}renderAsync(t,e){const n=this.render(t,e);return this._ready?this._ready.then((()=>n)):Promise.resolve(n)}_load(t,e){var n=this,r=n._loader[t](e);if(!n._ready){const t=n._call;n._ready=n._loader.ready().then((e=>{e&&t(),n._ready=null}))}return r}sanitizeURL(t){return this._load("sanitizeURL",t)}loadImage(t){return this._load("loadImage",t)}}const cv="dragenter",fv="dragleave",hv="dragover",dv="pointerdown",pv="pointermove",gv="pointerout",mv="pointerover",yv="mousedown",vv="mousemove",_v="mouseout",xv="mouseover",bv="click",wv="mousewheel",kv="touchstart",Av="touchmove",Mv="touchend",Ev=["keydown","keypress","keyup",cv,fv,hv,dv,"pointerup",pv,gv,mv,yv,"mouseup",vv,_v,xv,bv,"dblclick","wheel",wv,kv,Av,Mv],Dv=pv,Cv=_v,Fv=bv;class Sv extends sv{constructor(t,e){super(t,e),this._down=null,this._touch=null,this._first=!0,this._events={},this.events=Ev,this.pointermove=zv([pv,vv],[mv,xv],[gv,_v]),this.dragover=zv([hv],[cv],[fv]),this.pointerout=Nv([gv,_v]),this.dragleave=Nv([fv])}initialize(t,e,n){return this._canvas=t&&nv(t,"canvas"),[bv,yv,dv,pv,gv,fv].forEach((t=>Tv(this,t))),super.initialize(t,e,n)}canvas(){return this._canvas}context(){return this._canvas.getContext("2d")}DOMMouseScroll(t){this.fire(wv,t)}pointerdown(t){this._down=this._active,this.fire(dv,t)}mousedown(t){this._down=this._active,this.fire(yv,t)}click(t){this._down===this._active&&(this.fire(bv,t),this._down=null)}touchstart(t){this._touch=this.pickEvent(t.changedTouches[0]),this._first&&(this._active=this._touch,this._first=!1),this.fire(kv,t,!0)}touchmove(t){this.fire(Av,t,!0)}touchend(t){this.fire(Mv,t,!0),this._touch=null}fire(t,e,n){const r=n?this._touch:this._active,i=this._handlers[t];if(e.vegaType=t,t===Fv&&r&&r.href?this.handleHref(e,r,r.href):t!==Dv&&t!==Cv||this.handleTooltip(e,r,t!==Cv),i)for(let t=0,n=i.length;t=0&&r.splice(i,1),this}pickEvent(t){const e=av(t,this._canvas),n=this._origin;return this.pick(this._scene,e[0],e[1],e[0]-n[0],e[1]-n[1])}pick(t,e,n,r,i){const o=this.context();return Hy[t.marktype].pick.call(this,o,t,e,n,r,i)}}const $v=t=>t===kv||t===Av||t===Mv?[kv,Av,Mv]:[t];function Tv(t,e){$v(e).forEach((e=>function(t,e){const n=t.canvas();n&&!t._events[e]&&(t._events[e]=1,n.addEventListener(e,t[e]?n=>t[e](n):n=>t.fire(e,n)))}(t,e)))}function Bv(t,e,n){e.forEach((e=>t.fire(e,n)))}function zv(t,e,n){return function(r){const i=this._active,o=this.pickEvent(r);o===i||(i&&i.exit||Bv(this,n,r),this._active=o,Bv(this,e,r)),Bv(this,t,r)}}function Nv(t){return function(e){Bv(this,t,e),this._active=null}}function Ov(t,e,n,r,i,o){const a="undefined"!=typeof HTMLElement&&t instanceof HTMLElement&&null!=t.parentNode,s=t.getContext("2d"),u=a?"undefined"!=typeof window&&window.devicePixelRatio||1:i;t.width=e*u,t.height=n*u;for(const t in o)s[t]=o[t];return a&&1!==u&&(t.style.width=e+"px",t.style.height=n+"px"),s.pixelRatio=u,s.setTransform(u,0,0,u,u*r[0],u*r[1]),t}class Rv extends lv{constructor(t){super(t),this._options={},this._redraw=!1,this._dirty=new Vg,this._tempb=new Vg}initialize(t,e,n,r,i,o){return this._options=o||{},this._canvas=this._options.externalContext?null:$c(1,1,this._options.type),t&&this._canvas&&(iv(t,0).appendChild(this._canvas),this._canvas.setAttribute("class","marks")),super.initialize(t,e,n,r,i)}resize(t,e,n,r){if(super.resize(t,e,n,r),this._canvas)Ov(this._canvas,this._width,this._height,this._origin,this._scale,this._options.context);else{const t=this._options.externalContext;t||s("CanvasRenderer is missing a valid canvas or context"),t.scale(this._scale,this._scale),t.translate(this._origin[0],this._origin[1])}return this._redraw=!0,this}canvas(){return this._canvas}context(){return this._options.externalContext||(this._canvas?this._canvas.getContext("2d"):null)}dirty(t){const e=this._tempb.clear().union(t.bounds);let n=t.mark.group;for(;n;)e.translate(n.x||0,n.y||0),n=n.mark.group;this._dirty.union(e)}_render(t,e){const n=this.context(),r=this._origin,i=this._width,o=this._height,a=this._dirty,s=Uv(r,i,o);n.save();const u=this._redraw||a.empty()?(this._redraw=!1,s.expand(1)):function(t,e,n){e.expand(1).round(),t.pixelRatio%1&&e.scale(t.pixelRatio).round().scale(1/t.pixelRatio);return e.translate(-n[0]%1,-n[1]%1),t.beginPath(),t.rect(e.x1,e.y1,e.width(),e.height()),t.clip(),e}(n,s.intersect(a),r);return this.clear(-r[0],-r[1],i,o),this.draw(n,t,u,e),n.restore(),a.clear(),this}draw(t,e,n,r){if("group"!==e.marktype&&null!=r&&!r.includes(e.marktype))return;const i=Hy[e.marktype];e.clip&&function(t,e){var n=e.clip;t.save(),J(n)?(t.beginPath(),n(t),t.clip()):ty(t,e.group)}(t,e),i.draw.call(this,t,e,n,r),e.clip&&t.restore()}clear(t,e,n,r){const i=this._options,o=this.context();"pdf"===i.type||i.externalContext||o.clearRect(t,e,n,r),null!=this._bgcolor&&(o.fillStyle=this._bgcolor,o.fillRect(t,e,n,r))}}const Uv=(t,e,n)=>(new Vg).set(0,0,e,n).translate(-t[0],-t[1]);class Lv extends sv{constructor(t,e){super(t,e);const n=this;n._hrefHandler=qv(n,((t,e)=>{e&&e.href&&n.handleHref(t,e,e.href)})),n._tooltipHandler=qv(n,((t,e)=>{n.handleTooltip(t,e,t.type!==Cv)}))}initialize(t,e,n){let r=this._svg;return r&&(r.removeEventListener(Fv,this._hrefHandler),r.removeEventListener(Dv,this._tooltipHandler),r.removeEventListener(Cv,this._tooltipHandler)),this._svg=r=t&&nv(t,"svg"),r&&(r.addEventListener(Fv,this._hrefHandler),r.addEventListener(Dv,this._tooltipHandler),r.addEventListener(Cv,this._tooltipHandler)),super.initialize(t,e,n)}canvas(){return this._svg}on(t,e){const n=this.eventName(t),r=this._handlers;if(this._handlerIndex(r[n],t,e)<0){const i={type:t,handler:e,listener:qv(this,e)};(r[n]||(r[n]=[])).push(i),this._svg&&this._svg.addEventListener(n,i.listener)}return this}off(t,e){const n=this.eventName(t),r=this._handlers[n],i=this._handlerIndex(r,t,e);return i>=0&&(this._svg&&this._svg.removeEventListener(n,r[i].listener),r.splice(i,1)),this}}const qv=(t,e)=>n=>{let r=n.target.__data__;r=Array.isArray(r)?r[0]:r,n.vegaType=n.type,e.call(t._obj,n,r)},Pv="aria-hidden",jv="aria-label",Iv="role",Wv="aria-roledescription",Hv="graphics-object",Yv="graphics-symbol",Gv=(t,e,n)=>({[Iv]:t,[Wv]:e,[jv]:n||void 0}),Vv=Bt(["axis-domain","axis-grid","axis-label","axis-tick","axis-title","legend-band","legend-entry","legend-gradient","legend-label","legend-title","legend-symbol","title"]),Xv={axis:{desc:"axis",caption:function(t){const e=t.datum,n=t.orient,r=e.title?t_(t):null,i=t.context,o=i.scales[e.scale].value,a=i.dataflow.locale(),s=o.type;return("left"===n||"right"===n?"Y":"X")+"-axis"+(r?` titled '${r}'`:"")+` for a ${cp(s)?"discrete":s} scale`+` with ${Yp(a,o,t)}`}},legend:{desc:"legend",caption:function(t){const e=t.datum,n=e.title?t_(t):null,r=`${e.type||""} legend`.trim(),i=e.scales,o=Object.keys(i),a=t.context,s=a.scales[i[o[0]]].value,u=a.dataflow.locale();return l=r,(l.length?l[0].toUpperCase()+l.slice(1):l)+(n?` titled '${n}'`:"")+` for ${function(t){return t=t.map((t=>t+("fill"===t||"stroke"===t?" color":""))),t.length<2?t[0]:t.slice(0,-1).join(", ")+" and "+F(t)}(o)}`+` with ${Yp(u,s,t)}`;var l}},"title-text":{desc:"title",caption:t=>`Title text '${Kv(t)}'`},"title-subtitle":{desc:"subtitle",caption:t=>`Subtitle text '${Kv(t)}'`}},Jv={ariaRole:Iv,ariaRoleDescription:Wv,description:jv};function Zv(t,e){const n=!1===e.aria;if(t(Pv,n||void 0),n||null==e.description)for(const e in Jv)t(Jv[e],void 0);else{const n=e.mark.marktype;t(jv,e.description),t(Iv,e.ariaRole||("group"===n?Hv:Yv)),t(Wv,e.ariaRoleDescription||`${n} mark`)}}function Qv(t){return!1===t.aria?{[Pv]:!0}:Vv[t.role]?null:Xv[t.role]?function(t,e){try{const n=t.items[0],r=e.caption||(()=>"");return Gv(e.role||Yv,e.desc,n.description||r(n))}catch(t){return null}}(t,Xv[t.role]):function(t){const e=t.marktype,n="group"===e||"text"===e||t.items.some((t=>null!=t.description&&!1!==t.aria));return Gv(n?Hv:Yv,`${e} mark container`,t.description)}(t)}function Kv(t){return V(t.text).join(" ")}function t_(t){try{return V(F(t.items).items[0].text).join(" ")}catch(t){return null}}const e_=t=>(t+"").replace(/&/g,"&").replace(//g,">");function n_(){let t="",e="",n="";const r=[],i=()=>e=n="",o=(t,n)=>{var r;return null!=n&&(e+=` ${t}="${r=n,e_(r).replace(/"/g,""").replace(/\t/g," ").replace(/\n/g," ").replace(/\r/g," ")}"`),a},a={open(s){(o=>{e&&(t+=`${e}>${n}`,i()),r.push(o)})(s),e="<"+s;for(var u=arguments.length,l=new Array(u>1?u-1:0),c=1;c${n}`:"/>"):``,i(),a},attr:o,text:t=>(n+=e_(t),a),toString:()=>t};return a}const r_=t=>i_(n_(),t)+"";function i_(t,e){if(t.open(e.tagName),e.hasAttributes()){const n=e.attributes,r=n.length;for(let e=0;e{t.dirty=e}))),r.zdirty||(n.exit?(o.nested&&r.items.length?(u=r.items[0],u._svg&&this._update(o,u._svg,u)):n._svg&&(u=n._svg.parentNode,u&&u.removeChild(n._svg)),n._svg=null):(n=o.nested?r.items[0]:n,n._update!==e&&(n._svg&&n._svg.ownerSVGElement?this._update(o,n._svg,n):(this._dirtyAll=!1,f_(n,e)),n._update=e)));return!this._dirtyAll}mark(t,e,n,r){if(!this.isDirty(e))return e._svg;const i=this._svg,o=e.marktype,a=Hy[o],s=!1===e.interactive?"none":null,u="g"===a.tag,l=p_(e,t,n,"g",i);if("group"!==o&&null!=r&&!r.includes(o))return iv(l,0),e._svg;l.setAttribute("class",ov(e));const c=Qv(e);for(const t in c)b_(l,t,c[t]);u||b_(l,"pointer-events",s),b_(l,"clip-path",e.clip?Gg(this,e,e.group):null);let f=null,h=0;const d=t=>{const e=this.isDirty(t),n=p_(t,l,f,a.tag,i);e&&(this._update(a,n,t),u&&function(t,e,n,r){e=e.lastChild.previousSibling;let i,o=0;Lm(n,(n=>{i=t.mark(e,n,i,r),++o})),iv(e,1+o)}(this,n,t,r)),f=n,++h};return a.nested?e.items.length&&d(e.items[0]):Lm(e,d),iv(l,h),l}_update(t,e,n){g_=e,m_=e.__values__,Zv(v_,n),t.attr(v_,n,this);const r=y_[t.type];r&&r.call(this,t,e,n),g_&&this.style(g_,n)}style(t,e){if(null!=e){for(const n in o_){let r="font"===n?Oy(e):e[n];if(r===m_[n])continue;const i=o_[n];null==r?t.removeAttribute(i):(Xp(r)&&(r=Jp(r,this._defs.gradient,w_())),t.setAttribute(i,r+"")),m_[n]=r}for(const n in a_)__(t,a_[n],e[n])}}defs(){const t=this._svg,e=this._defs;let n=e.el,r=0;for(const i in e.gradient)n||(e.el=n=rv(t,1,"defs",l_)),r=h_(n,e.gradient[i],r);for(const i in e.clipping)n||(e.el=n=rv(t,1,"defs",l_)),r=d_(n,e.clipping[i],r);n&&(0===r?(t.removeChild(n),e.el=null):iv(n,r))}_clearDefs(){const t=this._defs;t.gradient={},t.clipping={}}}function f_(t,e){for(;t&&t.dirty!==e;t=t.mark.group){if(t.dirty=e,!t.mark||t.mark.dirty===e)return;t.mark.dirty=e}}function h_(t,e,n){let r,i,o;if("radial"===e.gradient){let r=rv(t,n++,"pattern",l_);x_(r,{id:Vp+e.id,viewBox:"0,0,1,1",width:"100%",height:"100%",preserveAspectRatio:"xMidYMid slice"}),r=rv(r,0,"rect",l_),x_(r,{width:1,height:1,fill:`url(${w_()}#${e.id})`}),x_(t=rv(t,n++,"radialGradient",l_),{id:e.id,fx:e.x1,fy:e.y1,fr:e.r1,cx:e.x2,cy:e.y2,r:e.r2})}else x_(t=rv(t,n++,"linearGradient",l_),{id:e.id,x1:e.x1,x2:e.x2,y1:e.y1,y2:e.y2});for(r=0,i=e.stops.length;r1&&t.previousSibling!=e}(a,n))&&e.insertBefore(a,n?n.nextSibling:e.firstChild),a}let g_=null,m_=null;const y_={group(t,e,n){const r=g_=e.childNodes[2];m_=r.__values__,t.foreground(v_,n,this),m_=e.__values__,g_=e.childNodes[1],t.content(v_,n,this);const i=g_=e.childNodes[0];t.background(v_,n,this);const o=!1===n.mark.interactive?"none":null;if(o!==m_.events&&(b_(r,"pointer-events",o),b_(i,"pointer-events",o),m_.events=o),n.strokeForeground&&n.stroke){const t=n.fill;b_(r,"display",null),this.style(i,n),b_(i,"stroke",null),t&&(n.fill=null),m_=r.__values__,this.style(r,n),t&&(n.fill=t),g_=null}else b_(r,"display","none")},image(t,e,n){!1===n.smooth?(__(e,"image-rendering","optimizeSpeed"),__(e,"image-rendering","pixelated")):__(e,"image-rendering",null)},text(t,e,n){const r=By(n);let i,o,a,s;k(r)?(o=r.map((t=>Ny(n,t))),i=o.join("\n"),i!==m_.text&&(iv(e,0),a=e.ownerDocument,s=Ty(n),o.forEach(((t,r)=>{const i=ev(a,"tspan",l_);i.__data__=n,i.textContent=t,r&&(i.setAttribute("x",0),i.setAttribute("dy",s)),e.appendChild(i)})),m_.text=i)):(o=Ny(n,r),o!==m_.text&&(e.textContent=o,m_.text=o)),b_(e,"font-family",Oy(n)),b_(e,"font-size",$y(n)+"px"),b_(e,"font-style",n.fontStyle),b_(e,"font-variant",n.fontVariant),b_(e,"font-weight",n.fontWeight)}};function v_(t,e,n){e!==m_[t]&&(n?function(t,e,n,r){null!=n?t.setAttributeNS(r,e,n):t.removeAttributeNS(r,e)}(g_,t,e,n):b_(g_,t,e),m_[t]=e)}function __(t,e,n){n!==m_[e]&&(null==n?t.style.removeProperty(e):t.style.setProperty(e,n+""),m_[e]=n)}function x_(t,e){for(const n in e)b_(t,n,e[n])}function b_(t,e,n){null!=n?t.setAttribute(e,n):t.removeAttribute(e)}function w_(){let t;return"undefined"==typeof window?"":(t=window.location).hash?t.href.slice(0,-t.hash.length):t.href}class k_ extends lv{constructor(t){super(t),this._text=null,this._defs={gradient:{},clipping:{}}}svg(){return this._text}_render(t){const e=n_();e.open("svg",ot({},uy,{class:"marks",width:this._width*this._scale,height:this._height*this._scale,viewBox:`0 0 ${this._width} ${this._height}`}));const n=this._bgcolor;return n&&"transparent"!==n&&"none"!==n&&e.open("rect",{width:this._width,height:this._height,fill:n}).close(),e.open("g",s_,{transform:"translate("+this._origin+")"}),this.mark(e,t),e.close(),this.defs(e),this._text=e.close()+"",this}mark(t,e){const n=Hy[e.marktype],r=n.tag,i=[Zv,n.attr];t.open("g",{class:ov(e),"clip-path":e.clip?Gg(this,e,e.group):null},Qv(e),{"pointer-events":"g"!==r&&!1===e.interactive?"none":null});const o=o=>{const a=this.href(o);if(a&&t.open("a",a),t.open(r,this.attr(e,o,i,"g"!==r?r:null)),"text"===r){const e=By(o);if(k(e)){const n={x:0,dy:Ty(o)};for(let r=0;rthis.mark(t,e))),t.close(),r&&a?(i&&(o.fill=null),o.stroke=a,t.open("path",this.attr(e,o,n.foreground,"bgrect")).close(),i&&(o.fill=i)):t.open("path",this.attr(e,o,n.foreground,"bgfore")).close()}t.close(),a&&t.close()};return n.nested?e.items&&e.items.length&&o(e.items[0]):Lm(e,o),t.close()}href(t){const e=t.href;let n;if(e){if(n=this._hrefs&&this._hrefs[e])return n;this.sanitizeURL(e).then((t=>{t["xlink:href"]=t.href,t.href=null,(this._hrefs||(this._hrefs={}))[e]=t}))}return null}attr(t,e,n,r){const i={},o=(t,e,n,r)=>{i[r||t]=e};return Array.isArray(n)?n.forEach((t=>t(o,e,this))):n(o,e,this),r&&function(t,e,n,r,i){let o;if(null==e)return t;"bgrect"===r&&!1===n.interactive&&(t["pointer-events"]="none");if("bgfore"===r&&(!1===n.interactive&&(t["pointer-events"]="none"),t.display="none",null!==e.fill))return t;"image"===r&&!1===e.smooth&&(o=["image-rendering: optimizeSpeed;","image-rendering: pixelated;"]);"text"===r&&(t["font-family"]=Oy(e),t["font-size"]=$y(e)+"px",t["font-style"]=e.fontStyle,t["font-variant"]=e.fontVariant,t["font-weight"]=e.fontWeight);for(const n in o_){let r=e[n];const o=o_[n];("transparent"!==r||"fill"!==o&&"stroke"!==o)&&null!=r&&(Xp(r)&&(r=Jp(r,i.gradient,"")),t[o]=r)}for(const t in a_){const n=e[t];null!=n&&(o=o||[],o.push(`${a_[t]}: ${n};`))}o&&(t.style=o.join(" "))}(i,e,t,r,this._defs),i}defs(t){const e=this._defs.gradient,n=this._defs.clipping;if(0!==Object.keys(e).length+Object.keys(n).length){t.open("defs");for(const n in e){const r=e[n],i=r.stops;"radial"===r.gradient?(t.open("pattern",{id:Vp+n,viewBox:"0,0,1,1",width:"100%",height:"100%",preserveAspectRatio:"xMidYMid slice"}),t.open("rect",{width:"1",height:"1",fill:"url(#"+n+")"}).close(),t.close(),t.open("radialGradient",{id:n,fx:r.x1,fy:r.y1,fr:r.r1,cx:r.x2,cy:r.y2,r:r.r2})):t.open("linearGradient",{id:n,x1:r.x1,x2:r.x2,y1:r.y1,y2:r.y2});for(let e=0;e!A_.svgMarkTypes.includes(t)));this._svgRenderer.render(t,A_.svgMarkTypes),this._canvasRenderer.render(t,n)}resize(t,e,n,r){return super.resize(t,e,n,r),this._svgRenderer.resize(t,e,n,r),this._canvasRenderer.resize(t,e,n,r),this}background(t){return A_.svgOnTop?this._canvasRenderer.background(t):this._svgRenderer.background(t),this}}class E_ extends Sv{constructor(t,e){super(t,e)}initialize(t,e,n){const r=rv(rv(t,0,"div"),A_.svgOnTop?0:1,"div");return super.initialize(r,e,n)}}const D_="canvas",C_="hybrid",F_="none",S_={Canvas:D_,PNG:"png",SVG:"svg",Hybrid:C_,None:F_},$_={};function T_(t,e){return t=String(t||"").toLowerCase(),arguments.length>1?($_[t]=e,this):$_[t]}function B_(t,e,n){const r=[],i=(new Vg).union(e),o=t.marktype;return o?z_(t,i,n,r):"group"===o?N_(t,i,n,r):s("Intersect scene must be mark node or group item.")}function z_(t,e,n,r){if(function(t,e,n){return t.bounds&&e.intersects(t.bounds)&&("group"===t.marktype||!1!==t.interactive&&(!n||n(t)))}(t,e,n)){const i=t.items,o=t.marktype,a=i.length;let s=0;if("group"===o)for(;s=0;r--)if(i[r]!=o[r])return!1;for(r=i.length-1;r>=0;r--)if(!q_(t[n=i[r]],e[n],n))return!1;return typeof t==typeof e}(t,e):t==e)}function P_(t,e){return q_(ag(t),ag(e))}const j_="top",I_="left",W_="right",H_="bottom",Y_="top-left",G_="top-right",V_="bottom-left",X_="bottom-right",J_="start",Z_="middle",Q_="end",K_="x",tx="y",ex="group",nx="axis",rx="title",ix="frame",ox="scope",ax="legend",sx="row-header",ux="row-footer",lx="row-title",cx="column-header",fx="column-footer",hx="column-title",dx="padding",px="symbol",gx="fit",mx="fit-x",yx="fit-y",vx="pad",_x="none",xx="all",bx="each",wx="flush",kx="column",Ax="row";function Mx(t){Ja.call(this,null,t)}function Ex(t,e,n){return e(t.bounds.clear(),t,n)}dt(Mx,Ja,{transform(t,e){const n=e.dataflow,r=t.mark,i=r.marktype,o=Hy[i],a=o.bound;let s,u=r.bounds;if(o.nested)r.items.length&&n.dirty(r.items[0]),u=Ex(r,a),r.items.forEach((t=>{t.bounds.clear().union(u)}));else if(i===ex||t.modified())switch(e.visit(e.MOD,(t=>n.dirty(t))),u.clear(),r.items.forEach((t=>u.union(Ex(t,a)))),r.role){case nx:case ax:case rx:e.reflow()}else s=e.changed(e.REM),e.visit(e.ADD,(t=>{u.union(Ex(t,a))})),e.visit(e.MOD,(t=>{s=s||u.alignsWith(t.bounds),n.dirty(t),u.union(Ex(t,a))})),s&&(u.clear(),r.items.forEach((t=>u.union(t.bounds))));return U_(r),e.modifies("bounds")}});const Dx=":vega_identifier:";function Cx(t){Ja.call(this,0,t)}function Fx(t){Ja.call(this,null,t)}function Sx(t){Ja.call(this,null,t)}Cx.Definition={type:"Identifier",metadata:{modifies:!0},params:[{name:"as",type:"string",required:!0}]},dt(Cx,Ja,{transform(t,e){const n=(i=e.dataflow)._signals[Dx]||(i._signals[Dx]=i.add(0)),r=t.as;var i;let o=n.value;return e.visit(e.ADD,(t=>t[r]=t[r]||++o)),n.set(this.value=o),e}}),dt(Fx,Ja,{transform(t,e){let n=this.value;n||(n=e.dataflow.scenegraph().mark(t.markdef,function(t){const e=t.groups,n=t.parent;return e&&1===e.size?e.get(Object.keys(e.object)[0]):e&&n?e.lookup(n):null}(t),t.index),n.group.context=t.context,t.context.group||(t.context.group=n.group),n.source=this.source,n.clip=t.clip,n.interactive=t.interactive,this.value=n);const r=n.marktype===ex?Jg:Xg;return e.visit(e.ADD,(t=>r.call(t,n))),(t.modified("clip")||t.modified("interactive"))&&(n.clip=t.clip,n.interactive=!!t.interactive,n.zdirty=!0,e.reflow()),n.items=e.source,e}});const $x={parity:t=>t.filter(((t,e)=>e%2?t.opacity=0:1)),greedy:(t,e)=>{let n;return t.filter(((t,r)=>r&&Tx(n.bounds,t.bounds,e)?t.opacity=0:(n=t,1)))}},Tx=(t,e,n)=>n>Math.max(e.x1-t.x2,t.x1-e.x2,e.y1-t.y2,t.y1-e.y2),Bx=(t,e)=>{for(var n,r=1,i=t.length,o=t[0].bounds;r{const e=t.bounds;return e.width()>1&&e.height()>1},Nx=t=>(t.forEach((t=>t.opacity=1)),t),Ox=(t,e)=>t.reflow(e.modified()).modifies("opacity");function Rx(t){Ja.call(this,null,t)}dt(Sx,Ja,{transform(t,e){const n=$x[t.method]||$x.parity,r=t.separation||0;let i,o,a=e.materialize(e.SOURCE).source;if(!a||!a.length)return;if(!t.method)return t.modified("method")&&(Nx(a),e=Ox(e,t)),e;if(a=a.filter(zx),!a.length)return;if(t.sort&&(a=a.slice().sort(t.sort)),i=Nx(a),e=Ox(e,t),i.length>=3&&Bx(i,r)){do{i=n(i,r)}while(i.length>=3&&Bx(i,r));i.length<3&&!F(a).opacity&&(i.length>1&&(F(i).opacity=0),F(a).opacity=1)}t.boundScale&&t.boundTolerance>=0&&(o=((t,e,n)=>{var r=t.range(),i=new Vg;return e===j_||e===H_?i.set(r[0],-1/0,r[1],1/0):i.set(-1/0,r[0],1/0,r[1]),i.expand(n||1),t=>i.encloses(t.bounds)})(t.boundScale,t.boundOrient,+t.boundTolerance),a.forEach((t=>{o(t)||(t.opacity=0)})));const s=i[0].mark.bounds.clear();return a.forEach((t=>{t.opacity&&s.union(t.bounds)})),e}}),dt(Rx,Ja,{transform(t,e){const n=e.dataflow;if(e.visit(e.ALL,(t=>n.dirty(t))),e.fields&&e.fields.zindex){const t=e.source&&e.source[0];t&&(t.mark.zdirty=!0)}}});const Ux=new Vg;function Lx(t,e,n){return t[e]===n?0:(t[e]=n,1)}function qx(t){var e=t.items[0].orient;return e===I_||e===W_}function Px(t,e,n,r){var i,o,a=e.items[0],s=a.datum,u=null!=a.translate?a.translate:.5,l=a.orient,c=function(t){let e=+t.grid;return[t.ticks?e++:-1,t.labels?e++:-1,e+ +t.domain]}(s),f=a.range,h=a.offset,d=a.position,p=a.minExtent,g=a.maxExtent,m=s.title&&a.items[c[2]].items[0],y=a.titlePadding,v=a.bounds,_=m&&zy(m),x=0,b=0;switch(Ux.clear().union(v),v.clear(),(i=c[0])>-1&&v.union(a.items[i].bounds),(i=c[1])>-1&&v.union(a.items[i].bounds),l){case j_:x=d||0,b=-h,o=Math.max(p,Math.min(g,-v.y1)),v.add(0,-o).add(f,0),m&&jx(t,m,o,y,_,0,-1,v);break;case I_:x=-h,b=d||0,o=Math.max(p,Math.min(g,-v.x1)),v.add(-o,0).add(0,f),m&&jx(t,m,o,y,_,1,-1,v);break;case W_:x=n+h,b=d||0,o=Math.max(p,Math.min(g,v.x2)),v.add(0,0).add(o,f),m&&jx(t,m,o,y,_,1,1,v);break;case H_:x=d||0,b=r+h,o=Math.max(p,Math.min(g,v.y2)),v.add(0,0).add(f,o),m&&jx(t,m,o,y,0,0,1,v);break;default:x=a.x,b=a.y}return tm(v.translate(x,b),a),Lx(a,"x",x+u)|Lx(a,"y",b+u)&&(a.bounds=Ux,t.dirty(a),a.bounds=v,t.dirty(a)),a.mark.bounds.clear().union(v)}function jx(t,e,n,r,i,o,a,s){const u=e.bounds;if(e.auto){const s=a*(n+i+r);let l=0,c=0;t.dirty(e),o?l=(e.x||0)-(e.x=s):c=(e.y||0)-(e.y=s),e.mark.bounds.clear().union(u.translate(-l,-c)),t.dirty(e)}s.union(u)}const Ix=(t,e)=>Math.floor(Math.min(t,e)),Wx=(t,e)=>Math.ceil(Math.max(t,e));function Hx(t){return(new Vg).set(0,0,t.width||0,t.height||0)}function Yx(t){const e=t.bounds.clone();return e.empty()?e.set(0,0,0,0):e.translate(-(t.x||0),-(t.y||0))}function Gx(t,e,n){const r=A(t)?t[e]:t;return null!=r?r:void 0!==n?n:0}function Vx(t){return t<0?Math.ceil(-t):0}function Xx(t,e,n){var r,i,o,a,s,u,l,c,f,h,d,p=!n.nodirty,g=n.bounds===wx?Hx:Yx,m=Ux.set(0,0,0,0),y=Gx(n.align,kx),v=Gx(n.align,Ax),_=Gx(n.padding,kx),x=Gx(n.padding,Ax),b=n.columns||e.length,w=b<=0?1:Math.ceil(e.length/b),k=e.length,A=Array(k),M=Array(b),E=0,D=Array(k),C=Array(w),F=0,S=Array(k),$=Array(k),T=Array(k);for(i=0;i1)for(i=0;i0&&(S[i]+=f/2);if(v&&Gx(n.center,Ax)&&1!==b)for(i=0;i0&&($[i]+=h/2);for(i=0;ii&&(t.warn("Grid headers exceed limit: "+i),e=e.slice(0,i)),A+=o,g=0,y=e.length;g=0&&null==(x=n[m]);m-=h);s?(b=null==d?x.x:Math.round(x.bounds.x1+d*x.bounds.width()),w=A):(b=A,w=null==d?x.y:Math.round(x.bounds.y1+d*x.bounds.height())),v.union(_.bounds.translate(b-(_.x||0),w-(_.y||0))),_.x=b,_.y=w,t.dirty(_),M=a(M,v[l])}return M}function tb(t,e,n,r,i,o){if(e){t.dirty(e);var a=n,s=n;r?a=Math.round(i.x1+o*i.width()):s=Math.round(i.y1+o*i.height()),e.bounds.translate(a-(e.x||0),s-(e.y||0)),e.mark.bounds.clear().union(e.bounds),e.x=a,e.y=s,t.dirty(e)}}function eb(t,e,n,r,i,o,a){const s=function(t,e){const n=t[e]||{};return(e,r)=>null!=n[e]?n[e]:null!=t[e]?t[e]:r}(n,e),u=function(t,e){let n=-1/0;return t.forEach((t=>{null!=t.offset&&(n=Math.max(n,t.offset))})),n>-1/0?n:e}(t,s("offset",0)),l=s("anchor",J_),c=l===Q_?1:l===Z_?.5:0,f={align:bx,bounds:s("bounds",wx),columns:"vertical"===s("direction")?1:t.length,padding:s("margin",8),center:s("center"),nodirty:!0};switch(e){case I_:f.anchor={x:Math.floor(r.x1)-u,column:Q_,y:c*(a||r.height()+2*r.y1),row:l};break;case W_:f.anchor={x:Math.ceil(r.x2)+u,y:c*(a||r.height()+2*r.y1),row:l};break;case j_:f.anchor={y:Math.floor(i.y1)-u,row:Q_,x:c*(o||i.width()+2*i.x1),column:l};break;case H_:f.anchor={y:Math.ceil(i.y2)+u,x:c*(o||i.width()+2*i.x1),column:l};break;case Y_:f.anchor={x:u,y:u};break;case G_:f.anchor={x:o-u,y:u,column:Q_};break;case V_:f.anchor={x:u,y:a-u,row:Q_};break;case X_:f.anchor={x:o-u,y:a-u,column:Q_,row:Q_}}return f}function nb(t,e){var n,r,i=e.items[0],o=i.datum,a=i.orient,s=i.bounds,u=i.x,l=i.y;return i._bounds?i._bounds.clear().union(s):i._bounds=s.clone(),s.clear(),function(t,e,n){var r=e.padding,i=r-n.x,o=r-n.y;if(e.datum.title){var a=e.items[1].items[0],s=a.anchor,u=e.titlePadding||0,l=r-a.x,c=r-a.y;switch(a.orient){case I_:i+=Math.ceil(a.bounds.width())+u;break;case W_:case H_:break;default:o+=a.bounds.height()+u}switch((i||o)&&ib(t,n,i,o),a.orient){case I_:c+=rb(e,n,a,s,1,1);break;case W_:l+=rb(e,n,a,Q_,0,0)+u,c+=rb(e,n,a,s,1,1);break;case H_:l+=rb(e,n,a,s,0,0),c+=rb(e,n,a,Q_,-1,0,1)+u;break;default:l+=rb(e,n,a,s,0,0)}(l||c)&&ib(t,a,l,c),(l=Math.round(a.bounds.x1-r))<0&&(ib(t,n,-l,0),ib(t,a,-l,0))}else(i||o)&&ib(t,n,i,o)}(t,i,i.items[0].items[0]),s=function(t,e){return t.items.forEach((t=>e.union(t.bounds))),e.x1=t.padding,e.y1=t.padding,e}(i,s),n=2*i.padding,r=2*i.padding,s.empty()||(n=Math.ceil(s.width()+n),r=Math.ceil(s.height()+r)),o.type===px&&function(t){const e=t.reduce(((t,e)=>(t[e.column]=Math.max(e.bounds.x2-e.x,t[e.column]||0),t)),{});t.forEach((t=>{t.width=e[t.column],t.height=t.bounds.y2-t.y}))}(i.items[0].items[0].items[0].items),a!==_x&&(i.x=u=0,i.y=l=0),i.width=n,i.height=r,tm(s.set(u,l,u+n,l+r),i),i.mark.bounds.clear().union(s),i}function rb(t,e,n,r,i,o,a){const s="symbol"!==t.datum.type,u=n.datum.vgrad,l=(!s||!o&&u||a?e:e.items[0]).bounds[i?"y2":"x2"]-t.padding,c=u&&o?l:0,f=u&&o?0:l,h=i<=0?0:zy(n);return Math.round(r===J_?c:r===Q_?f-h:.5*(l-h))}function ib(t,e,n,r){e.x+=n,e.y+=r,e.bounds.translate(n,r),e.mark.bounds.translate(n,r),t.dirty(e)}function ob(t){Ja.call(this,null,t)}dt(ob,Ja,{transform(t,e){const n=e.dataflow;return t.mark.items.forEach((e=>{t.layout&&Jx(n,e,t.layout),function(t,e,n){var r,i,o,a,s,u=e.items,l=Math.max(0,e.width||0),c=Math.max(0,e.height||0),f=(new Vg).set(0,0,l,c),h=f.clone(),d=f.clone(),p=[];for(a=0,s=u.length;a{(o=t.orient||W_)!==_x&&(e[o]||(e[o]=[])).push(t)}));for(const r in e){const i=e[r];Xx(t,i,eb(i,r,n.legends,h,d,l,c))}p.forEach((e=>{const r=e.bounds;if(r.equals(e._bounds)||(e.bounds=e._bounds,t.dirty(e),e.bounds=r,t.dirty(e)),!n.autosize||n.autosize.type!==gx&&n.autosize.type!==mx&&n.autosize.type!==yx)f.union(r);else switch(e.orient){case I_:case W_:f.add(r.x1,0).add(r.x2,0);break;case j_:case H_:f.add(0,r.y1).add(0,r.y2)}}))}f.union(h).union(d),r&&f.union(function(t,e,n,r,i){var o,a=e.items[0],s=a.frame,u=a.orient,l=a.anchor,c=a.offset,f=a.padding,h=a.items[0].items[0],d=a.items[1]&&a.items[1].items[0],p=u===I_||u===W_?r:n,g=0,m=0,y=0,v=0,_=0;if(s!==ex?u===I_?(g=i.y2,p=i.y1):u===W_?(g=i.y1,p=i.y2):(g=i.x1,p=i.x2):u===I_&&(g=r,p=0),o=l===J_?g:l===Q_?p:(g+p)/2,d&&d.text){switch(u){case j_:case H_:_=h.bounds.height()+f;break;case I_:v=h.bounds.width()+f;break;case W_:v=-h.bounds.width()-f}Ux.clear().union(d.bounds),Ux.translate(v-(d.x||0),_-(d.y||0)),Lx(d,"x",v)|Lx(d,"y",_)&&(t.dirty(d),d.bounds.clear().union(Ux),d.mark.bounds.clear().union(Ux),t.dirty(d)),Ux.clear().union(d.bounds)}else Ux.clear();switch(Ux.union(h.bounds),u){case j_:m=o,y=i.y1-Ux.height()-c;break;case I_:m=i.x1-Ux.width()-c,y=o;break;case W_:m=i.x2+Ux.width()+c,y=o;break;case H_:m=o,y=i.y2+c;break;default:m=a.x,y=a.y}return Lx(a,"x",m)|Lx(a,"y",y)&&(Ux.translate(m,y),t.dirty(a),a.bounds.clear().union(Ux),e.bounds.clear().union(Ux),t.dirty(a)),a.bounds}(t,r,l,c,f));e.clip&&f.set(0,0,e.width||0,e.height||0);!function(t,e,n,r){const i=r.autosize||{},o=i.type;if(t._autosize<1||!o)return;let a=t._width,s=t._height,u=Math.max(0,e.width||0),l=Math.max(0,Math.ceil(-n.x1)),c=Math.max(0,e.height||0),f=Math.max(0,Math.ceil(-n.y1));const h=Math.max(0,Math.ceil(n.x2-u)),d=Math.max(0,Math.ceil(n.y2-c));if(i.contains===dx){const e=t.padding();a-=e.left+e.right,s-=e.top+e.bottom}o===_x?(l=0,f=0,u=a,c=s):o===gx?(u=Math.max(0,a-l-h),c=Math.max(0,s-f-d)):o===mx?(u=Math.max(0,a-l-h),s=c+f+d):o===yx?(a=u+l+h,c=Math.max(0,s-f-d)):o===vx&&(a=u+l+h,s=c+f+d);t._resizeView(a,s,u,c,[l,f],i.resize)}(t,e,f,n)}(n,e,t)})),function(t){return t&&"legend-entry"!==t.mark.role}(t.mark.group)?e.reflow():e}});var ab=Object.freeze({__proto__:null,bound:Mx,identifier:Cx,mark:Fx,overlap:Sx,render:Rx,viewlayout:ob});function sb(t){Ja.call(this,null,t)}function ub(t){Ja.call(this,null,t)}function lb(){return _a({})}function cb(t){Ja.call(this,null,t)}function fb(t){Ja.call(this,[],t)}dt(sb,Ja,{transform(t,e){if(this.value&&!t.modified())return e.StopPropagation;var n=e.dataflow.locale(),r=e.fork(e.NO_SOURCE|e.NO_FIELDS),i=this.value,o=t.scale,a=Sp(o,null==t.count?t.values?t.values.length:10:t.count,t.minstep),s=t.format||Bp(n,o,a,t.formatSpecifier,t.formatType,!!t.values),u=t.values?$p(o,t.values,a):Tp(o,a);return i&&(r.rem=i),i=u.map(((t,e)=>_a({index:e/(u.length-1||1),value:t,label:s(t)}))),t.extra&&i.length&&i.push(_a({index:-1,extra:{value:i[0].value},label:""})),r.source=i,r.add=i,this.value=i,r}}),dt(ub,Ja,{transform(t,e){var n=e.dataflow,r=e.fork(e.NO_SOURCE|e.NO_FIELDS),i=t.item||lb,o=t.key||ya,a=this.value;return k(r.encode)&&(r.encode=null),a&&(t.modified("key")||e.modified(o))&&s("DataJoin does not support modified key function or fields."),a||(e=e.addAll(),this.value=a=function(t){const e=ft().test((t=>t.exit));return e.lookup=n=>e.get(t(n)),e}(o)),e.visit(e.ADD,(t=>{const e=o(t);let n=a.get(e);n?n.exit?(a.empty--,r.add.push(n)):r.mod.push(n):(n=i(t),a.set(e,n),r.add.push(n)),n.datum=t,n.exit=!1})),e.visit(e.MOD,(t=>{const e=o(t),n=a.get(e);n&&(n.datum=t,r.mod.push(n))})),e.visit(e.REM,(t=>{const e=o(t),n=a.get(e);t!==n.datum||n.exit||(r.rem.push(n),n.exit=!0,++a.empty)})),e.changed(e.ADD_MOD)&&r.modifies("datum"),(e.clean()||t.clean&&a.empty>n.cleanThreshold)&&n.runAfter(a.clean),r}}),dt(cb,Ja,{transform(t,e){var n=e.fork(e.ADD_REM),r=t.mod||!1,i=t.encoders,o=e.encode;if(k(o)){if(!n.changed()&&!o.every((t=>i[t])))return e.StopPropagation;o=o[0],n.encode=null}var a="enter"===o,s=i.update||g,u=i.enter||g,l=i.exit||g,c=(o&&!a?i[o]:s)||g;if(e.changed(e.ADD)&&(e.visit(e.ADD,(e=>{u(e,t),s(e,t)})),n.modifies(u.output),n.modifies(s.output),c!==g&&c!==s&&(e.visit(e.ADD,(e=>{c(e,t)})),n.modifies(c.output))),e.changed(e.REM)&&l!==g&&(e.visit(e.REM,(e=>{l(e,t)})),n.modifies(l.output)),a||c!==g){const i=e.MOD|(t.modified()?e.REFLOW:0);a?(e.visit(i,(e=>{const i=u(e,t)||r;(c(e,t)||i)&&n.mod.push(e)})),n.mod.length&&n.modifies(u.output)):e.visit(i,(e=>{(c(e,t)||r)&&n.mod.push(e)})),n.mod.length&&n.modifies(c.output)}return n.changed()?n:e.StopPropagation}}),dt(fb,Ja,{transform(t,e){if(null!=this.value&&!t.modified())return e.StopPropagation;var n,r,i,o,a,s=e.dataflow.locale(),u=e.fork(e.NO_SOURCE|e.NO_FIELDS),l=this.value,c=t.type||Mp,f=t.scale,h=+t.limit,d=Sp(f,null==t.count?5:t.count,t.minstep),p=!!t.values||c===Mp,g=t.format||Lp(s,f,d,c,t.formatSpecifier,t.formatType,p),m=t.values||Rp(f,d);return l&&(u.rem=l),c===Mp?(h&&m.length>h?(e.dataflow.warn("Symbol legend count exceeds limit, filtering items."),l=m.slice(0,h-1),a=!0):l=m,J(i=t.size)?(t.values||0!==f(l[0])||(l=l.slice(1)),o=l.reduce(((e,n)=>Math.max(e,i(n,t))),0)):i=rt(o=i||8),l=l.map(((e,n)=>_a({index:n,label:g(e,n,l),value:e,offset:o,size:i(e,t)}))),a&&(a=m[l.length],l.push(_a({index:l.length,label:`…${m.length-l.length} entries`,value:a,offset:o,size:i(a,t)})))):"gradient"===c?(n=f.domain(),r=_p(f,n[0],F(n)),m.length<3&&!t.values&&n[0]!==F(n)&&(m=[n[0],F(n)]),l=m.map(((t,e)=>_a({index:e,label:g(t,e,m),value:t,perc:r(t)})))):(i=m.length-1,r=function(t){const e=t.domain(),n=e.length-1;let r=+e[0],i=+F(e),o=i-r;if(t.type===Id){const t=n?o/n:.1;r-=t,i+=t,o=i-r}return t=>(t-r)/o}(f),l=m.map(((t,e)=>_a({index:e,label:g(t,e,m),value:t,perc:e?r(t):0,perc2:e===i?1:r(m[e+1])})))),u.source=l,u.add=l,this.value=l,u}});const hb=t=>t.source.x,db=t=>t.source.y,pb=t=>t.target.x,gb=t=>t.target.y;function mb(t){Ja.call(this,{},t)}mb.Definition={type:"LinkPath",metadata:{modifies:!0},params:[{name:"sourceX",type:"field",default:"source.x"},{name:"sourceY",type:"field",default:"source.y"},{name:"targetX",type:"field",default:"target.x"},{name:"targetY",type:"field",default:"target.y"},{name:"orient",type:"enum",default:"vertical",values:["horizontal","vertical","radial"]},{name:"shape",type:"enum",default:"line",values:["line","arc","curve","diagonal","orthogonal"]},{name:"require",type:"signal"},{name:"as",type:"string",default:"path"}]},dt(mb,Ja,{transform(t,e){var n=t.sourceX||hb,r=t.sourceY||db,i=t.targetX||pb,o=t.targetY||gb,a=t.as||"path",u=t.orient||"vertical",l=t.shape||"line",c=xb.get(l+"-"+u)||xb.get(l);return c||s("LinkPath unsupported type: "+t.shape+(t.orient?"-"+t.orient:"")),e.visit(e.SOURCE,(t=>{t[a]=c(n(t),r(t),i(t),o(t))})),e.reflow(t.modified()).modifies(a)}});const yb=(t,e,n,r)=>"M"+t+","+e+"L"+n+","+r,vb=(t,e,n,r)=>{var i=n-t,o=r-e,a=Math.hypot(i,o)/2;return"M"+t+","+e+"A"+a+","+a+" "+180*Math.atan2(o,i)/Math.PI+" 0 1 "+n+","+r},_b=(t,e,n,r)=>{const i=n-t,o=r-e,a=.2*(i+o),s=.2*(o-i);return"M"+t+","+e+"C"+(t+a)+","+(e+s)+" "+(n+s)+","+(r-a)+" "+n+","+r},xb=ft({line:yb,"line-radial":(t,e,n,r)=>yb(e*Math.cos(t),e*Math.sin(t),r*Math.cos(n),r*Math.sin(n)),arc:vb,"arc-radial":(t,e,n,r)=>vb(e*Math.cos(t),e*Math.sin(t),r*Math.cos(n),r*Math.sin(n)),curve:_b,"curve-radial":(t,e,n,r)=>_b(e*Math.cos(t),e*Math.sin(t),r*Math.cos(n),r*Math.sin(n)),"orthogonal-horizontal":(t,e,n,r)=>"M"+t+","+e+"V"+r+"H"+n,"orthogonal-vertical":(t,e,n,r)=>"M"+t+","+e+"H"+n+"V"+r,"orthogonal-radial":(t,e,n,r)=>{const i=Math.cos(t),o=Math.sin(t),a=Math.cos(n),s=Math.sin(n);return"M"+e*i+","+e*o+"A"+e+","+e+" 0 0,"+((Math.abs(n-t)>Math.PI?n<=t:n>t)?1:0)+" "+e*a+","+e*s+"L"+r*a+","+r*s},"diagonal-horizontal":(t,e,n,r)=>{const i=(t+n)/2;return"M"+t+","+e+"C"+i+","+e+" "+i+","+r+" "+n+","+r},"diagonal-vertical":(t,e,n,r)=>{const i=(e+r)/2;return"M"+t+","+e+"C"+t+","+i+" "+n+","+i+" "+n+","+r},"diagonal-radial":(t,e,n,r)=>{const i=Math.cos(t),o=Math.sin(t),a=Math.cos(n),s=Math.sin(n),u=(e+r)/2;return"M"+e*i+","+e*o+"C"+u*i+","+u*o+" "+u*a+","+u*s+" "+r*a+","+r*s}});function bb(t){Ja.call(this,null,t)}bb.Definition={type:"Pie",metadata:{modifies:!0},params:[{name:"field",type:"field"},{name:"startAngle",type:"number",default:0},{name:"endAngle",type:"number",default:6.283185307179586},{name:"sort",type:"boolean",default:!1},{name:"as",type:"string",array:!0,length:2,default:["startAngle","endAngle"]}]},dt(bb,Ja,{transform(t,e){var n,r,i,o=t.as||["startAngle","endAngle"],a=o[0],s=o[1],u=t.field||d,l=t.startAngle||0,c=null!=t.endAngle?t.endAngle:2*Math.PI,f=e.source,h=f.map(u),p=h.length,g=l,m=(c-l)/$e(h),y=Se(p);for(t.sort&&y.sort(((t,e)=>h[t]-h[e])),n=0;nt+(e<0?-1:e>0?1:0)),0))!==e.length&&n.warn("Log scale domain includes zero: "+Ct(e)));return e}function Db(t,e,n){return J(t)&&(e||n)?mp(t,Cb(e||[0,1],n)):t}function Cb(t,e){return e?t.slice().reverse():t}function Fb(t){Ja.call(this,null,t)}dt(Mb,Ja,{transform(t,e){var n=e.dataflow,r=this.value,i=function(t){var e,n=t.type,r="";if(n===Ld)return Ld+"-"+Td;(function(t){const e=t.type;return lp(e)&&e!==Rd&&e!==Ud&&(t.scheme||t.range&&t.range.length&&t.range.every(xt))})(t)&&(r=2===(e=t.rawDomain?t.rawDomain.length:t.domain?t.domain.length+ +(null!=t.domainMid):0)?Ld+"-":3===e?qd+"-":"");return(r+n||Td).toLowerCase()}(t);for(i in r&&i===r.type||(this.value=r=ap(i)()),t)if(!Ab[i]){if("padding"===i&&kb(r.type))continue;J(r[i])?r[i](t[i]):n.warn("Unsupported scale property: "+i)}return function(t,e,n){var r=t.type,i=e.round||!1,o=e.range;if(null!=e.rangeStep)o=function(t,e,n){t!==Yd&&t!==Hd&&s("Only band and point scales support rangeStep.");var r=(null!=e.paddingOuter?e.paddingOuter:e.padding)||0,i=t===Hd?1:(null!=e.paddingInner?e.paddingInner:e.padding)||0;return[0,e.rangeStep*$d(n,i,r)]}(r,e,n);else if(e.scheme&&(o=function(t,e,n){var r,i=e.schemeExtent;k(e.scheme)?r=yp(e.scheme,e.interpolate,e.interpolateGamma):(r=Ap(e.scheme.toLowerCase()))||s(`Unrecognized scheme name: ${e.scheme}`);return n=t===Id?n+1:t===Gd?n-1:t===Pd||t===jd?+e.schemeCount||wb:n,dp(t)?Db(r,i,e.reverse):J(r)?vp(Db(r,i),n):t===Wd?r:r.slice(0,n)}(r,e,n),J(o))){if(t.interpolator)return t.interpolator(o);s(`Scale type ${r} does not support interpolating color schemes.`)}if(o&&dp(r))return t.interpolator(yp(Cb(o,e.reverse),e.interpolate,e.interpolateGamma));o&&e.interpolate&&t.interpolate?t.interpolate(xp(e.interpolate,e.interpolateGamma)):J(t.round)?t.round(i):J(t.rangeRound)&&t.interpolate(i?yh:mh);o&&t.range(Cb(o,e.reverse))}(r,t,function(t,e,n){let r=e.bins;if(r&&!k(r)){const e=t.domain(),n=e[0],i=F(e),o=r.step;let a=null==r.start?n:r.start,u=null==r.stop?i:r.stop;o||s("Scale bins parameter missing step property."),ai&&(u=o*Math.floor(i/o)),r=Se(a,u+o/2,o)}r?t.bins=r:t.bins&&delete t.bins;t.type===Gd&&(r?e.domain||e.domainRaw||(t.domain(r),n=r.length):t.bins=t.domain());return n}(r,t,function(t,e,n){const r=function(t,e,n){return e?(t.domain(Eb(t.type,e,n)),e.length):-1}(t,e.domainRaw,n);if(r>-1)return r;var i,o,a=e.domain,s=t.type,u=e.zero||void 0===e.zero&&function(t){const e=t.type;return!t.bins&&(e===Td||e===zd||e===Nd)}(t);if(!a)return 0;if((u||null!=e.domainMin||null!=e.domainMax||null!=e.domainMid)&&(i=(a=a.slice()).length-1||1,u&&(a[0]>0&&(a[0]=0),a[i]<0&&(a[i]=0)),null!=e.domainMin&&(a[0]=e.domainMin),null!=e.domainMax&&(a[i]=e.domainMax),null!=e.domainMid)){const t=(o=e.domainMid)>a[i]?i+1:ot(u);if(null==e)d.push(t.slice());else for(i={},o=0,a=t.length;oh&&(h=f),n&&c.sort(n)}return d.max=h,d}(e.source,t.groupby,l,c),r=0,i=n.length,o=n.max;r0?1:t<0?-1:0},iw=Math.sqrt,ow=Math.tan;function aw(t){return t>1?0:t<-1?Pb:Math.acos(t)}function sw(t){return t>1?jb:t<-1?-jb:Math.asin(t)}function uw(){}function lw(t,e){t&&fw.hasOwnProperty(t.type)&&fw[t.type](t,e)}var cw={Feature:function(t,e){lw(t.geometry,e)},FeatureCollection:function(t,e){for(var n=t.features,r=-1,i=n.length;++r=0?1:-1,i=r*n,o=Jb(e=(e*=Yb)/2+Ib),a=nw(e),s=_w*a,u=vw*o+s*Jb(i),l=s*r*nw(i);$w.add(Xb(l,u)),yw=t,vw=o,_w=a}function Uw(t){return[Xb(t[1],t[0]),sw(t[2])]}function Lw(t){var e=t[0],n=t[1],r=Jb(n);return[r*Jb(e),r*nw(e),nw(n)]}function qw(t,e){return t[0]*e[0]+t[1]*e[1]+t[2]*e[2]}function Pw(t,e){return[t[1]*e[2]-t[2]*e[1],t[2]*e[0]-t[0]*e[2],t[0]*e[1]-t[1]*e[0]]}function jw(t,e){t[0]+=e[0],t[1]+=e[1],t[2]+=e[2]}function Iw(t,e){return[t[0]*e,t[1]*e,t[2]*e]}function Ww(t){var e=iw(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=e,t[1]/=e,t[2]/=e}var Hw,Yw,Gw,Vw,Xw,Jw,Zw,Qw,Kw,tk,ek,nk,rk,ik,ok,ak,sk={point:uk,lineStart:ck,lineEnd:fk,polygonStart:function(){sk.point=hk,sk.lineStart=dk,sk.lineEnd=pk,Cw=new se,Bw.polygonStart()},polygonEnd:function(){Bw.polygonEnd(),sk.point=uk,sk.lineStart=ck,sk.lineEnd=fk,$w<0?(xw=-(ww=180),bw=-(kw=90)):Cw>Lb?kw=90:Cw<-Lb&&(bw=-90),Sw[0]=xw,Sw[1]=ww},sphere:function(){xw=-(ww=180),bw=-(kw=90)}};function uk(t,e){Fw.push(Sw=[xw=t,ww=t]),ekw&&(kw=e)}function lk(t,e){var n=Lw([t*Yb,e*Yb]);if(Dw){var r=Pw(Dw,n),i=Pw([r[1],-r[0],0],r);Ww(i),i=Uw(i);var o,a=t-Aw,s=a>0?1:-1,u=i[0]*Hb*s,l=Gb(a)>180;l^(s*Awkw&&(kw=o):l^(s*Aw<(u=(u+360)%360-180)&&ukw&&(kw=e)),l?tgk(xw,ww)&&(ww=t):gk(t,ww)>gk(xw,ww)&&(xw=t):ww>=xw?(tww&&(ww=t)):t>Aw?gk(xw,t)>gk(xw,ww)&&(ww=t):gk(t,ww)>gk(xw,ww)&&(xw=t)}else Fw.push(Sw=[xw=t,ww=t]);ekw&&(kw=e),Dw=n,Aw=t}function ck(){sk.point=lk}function fk(){Sw[0]=xw,Sw[1]=ww,sk.point=uk,Dw=null}function hk(t,e){if(Dw){var n=t-Aw;Cw.add(Gb(n)>180?n+(n>0?360:-360):n)}else Mw=t,Ew=e;Bw.point(t,e),lk(t,e)}function dk(){Bw.lineStart()}function pk(){hk(Mw,Ew),Bw.lineEnd(),Gb(Cw)>Lb&&(xw=-(ww=180)),Sw[0]=xw,Sw[1]=ww,Dw=null}function gk(t,e){return(e-=t)<0?e+360:e}function mk(t,e){return t[0]-e[0]}function yk(t,e){return t[0]<=t[1]?t[0]<=e&&e<=t[1]:ePb&&(t-=Math.round(t/Wb)*Wb),[t,e]}function $k(t,e,n){return(t%=Wb)?e||n?Fk(Bk(t),zk(e,n)):Bk(t):e||n?zk(e,n):Sk}function Tk(t){return function(e,n){return Gb(e+=t)>Pb&&(e-=Math.round(e/Wb)*Wb),[e,n]}}function Bk(t){var e=Tk(t);return e.invert=Tk(-t),e}function zk(t,e){var n=Jb(t),r=nw(t),i=Jb(e),o=nw(e);function a(t,e){var a=Jb(e),s=Jb(t)*a,u=nw(t)*a,l=nw(e),c=l*n+s*r;return[Xb(u*i-c*o,s*n-l*r),sw(c*i+u*o)]}return a.invert=function(t,e){var a=Jb(e),s=Jb(t)*a,u=nw(t)*a,l=nw(e),c=l*i-u*o;return[Xb(u*i+l*o,s*n+c*r),sw(c*n-s*r)]},a}function Nk(t,e){(e=Lw(e))[0]-=t,Ww(e);var n=aw(-e[1]);return((-e[2]<0?-n:n)+Wb-Lb)%Wb}function Ok(){var t,e=[];return{point:function(e,n,r){t.push([e,n,r])},lineStart:function(){e.push(t=[])},lineEnd:uw,rejoin:function(){e.length>1&&e.push(e.pop().concat(e.shift()))},result:function(){var n=e;return e=[],t=null,n}}}function Rk(t,e){return Gb(t[0]-e[0])=0;--o)i.point((c=l[o])[0],c[1]);else r(h.x,h.p.x,-1,i);h=h.p}l=(h=h.o).z,d=!d}while(!h.v);i.lineEnd()}}}function qk(t){if(e=t.length){for(var e,n,r=0,i=t[0];++r=0?1:-1,E=M*A,D=E>Pb,C=m*w;if(u.add(Xb(C*M*nw(E),y*k+C*Jb(E))),a+=D?A+M*Wb:A,D^p>=n^x>=n){var F=Pw(Lw(d),Lw(_));Ww(F);var S=Pw(o,F);Ww(S);var $=(D^A>=0?-1:1)*sw(S[2]);(r>$||r===$&&(F[0]||F[1]))&&(s+=D^A>=0?1:-1)}}return(a<-Lb||a0){for(f||(i.polygonStart(),f=!0),i.lineStart(),t=0;t1&&2&u&&h.push(h.pop().concat(h.shift())),a.push(h.filter(Ik))}return h}}function Ik(t){return t.length>1}function Wk(t,e){return((t=t.x)[0]<0?t[1]-jb-Lb:jb-t[1])-((e=e.x)[0]<0?e[1]-jb-Lb:jb-e[1])}Sk.invert=Sk;var Hk=jk((function(){return!0}),(function(t){var e,n=NaN,r=NaN,i=NaN;return{lineStart:function(){t.lineStart(),e=1},point:function(o,a){var s=o>0?Pb:-Pb,u=Gb(o-n);Gb(u-Pb)0?jb:-jb),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(s,r),t.point(o,r),e=0):i!==s&&u>=Pb&&(Gb(n-i)Lb?Vb((nw(e)*(o=Jb(r))*nw(n)-nw(r)*(i=Jb(e))*nw(t))/(i*o*a)):(e+r)/2}(n,r,o,a),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(s,r),e=0),t.point(n=o,r=a),i=s},lineEnd:function(){t.lineEnd(),n=r=NaN},clean:function(){return 2-e}}}),(function(t,e,n,r){var i;if(null==t)i=n*jb,r.point(-Pb,i),r.point(0,i),r.point(Pb,i),r.point(Pb,0),r.point(Pb,-i),r.point(0,-i),r.point(-Pb,-i),r.point(-Pb,0),r.point(-Pb,i);else if(Gb(t[0]-e[0])>Lb){var o=t[0]0,i=Gb(e)>Lb;function o(t,n){return Jb(t)*Jb(n)>e}function a(t,n,r){var i=[1,0,0],o=Pw(Lw(t),Lw(n)),a=qw(o,o),s=o[0],u=a-s*s;if(!u)return!r&&t;var l=e*a/u,c=-e*s/u,f=Pw(i,o),h=Iw(i,l);jw(h,Iw(o,c));var d=f,p=qw(h,d),g=qw(d,d),m=p*p-g*(qw(h,h)-1);if(!(m<0)){var y=iw(m),v=Iw(d,(-p-y)/g);if(jw(v,h),v=Uw(v),!r)return v;var _,x=t[0],b=n[0],w=t[1],k=n[1];b0^v[1]<(Gb(v[0]-x)Pb^(x<=v[0]&&v[0]<=b)){var E=Iw(d,(-p+y)/g);return jw(E,h),[v,Uw(E)]}}}function s(e,n){var i=r?t:Pb-t,o=0;return e<-i?o|=1:e>i&&(o|=2),n<-i?o|=4:n>i&&(o|=8),o}return jk(o,(function(t){var e,n,u,l,c;return{lineStart:function(){l=u=!1,c=1},point:function(f,h){var d,p=[f,h],g=o(f,h),m=r?g?0:s(f,h):g?s(f+(f<0?Pb:-Pb),h):0;if(!e&&(l=u=g)&&t.lineStart(),g!==u&&(!(d=a(e,p))||Rk(e,d)||Rk(p,d))&&(p[2]=1),g!==u)c=0,g?(t.lineStart(),d=a(p,e),t.point(d[0],d[1])):(d=a(e,p),t.point(d[0],d[1],2),t.lineEnd()),e=d;else if(i&&e&&r^g){var y;m&n||!(y=a(p,e,!0))||(c=0,r?(t.lineStart(),t.point(y[0][0],y[0][1]),t.point(y[1][0],y[1][1]),t.lineEnd()):(t.point(y[1][0],y[1][1]),t.lineEnd(),t.lineStart(),t.point(y[0][0],y[0][1],3)))}!g||e&&Rk(e,p)||t.point(p[0],p[1]),e=p,u=g,n=m},lineEnd:function(){u&&t.lineEnd(),e=null},clean:function(){return c|(l&&u)<<1}}}),(function(e,r,i,o){!function(t,e,n,r,i,o){if(n){var a=Jb(e),s=nw(e),u=r*n;null==i?(i=e+r*Wb,o=e-u/2):(i=Nk(a,i),o=Nk(a,o),(r>0?io)&&(i+=r*Wb));for(var l,c=i;r>0?c>o:c0)do{l.point(0===c||3===c?t:n,c>1?r:e)}while((c=(c+s+4)%4)!==f);else l.point(o[0],o[1])}function a(r,i){return Gb(r[0]-t)0?0:3:Gb(r[0]-n)0?2:1:Gb(r[1]-e)0?1:0:i>0?3:2}function s(t,e){return u(t.x,e.x)}function u(t,e){var n=a(t,1),r=a(e,1);return n!==r?n-r:0===n?e[1]-t[1]:1===n?t[0]-e[0]:2===n?t[1]-e[1]:e[0]-t[0]}return function(a){var u,l,c,f,h,d,p,g,m,y,v,_=a,x=Ok(),b={point:w,lineStart:function(){b.point=k,l&&l.push(c=[]);y=!0,m=!1,p=g=NaN},lineEnd:function(){u&&(k(f,h),d&&m&&x.rejoin(),u.push(x.result()));b.point=w,m&&_.lineEnd()},polygonStart:function(){_=x,u=[],l=[],v=!0},polygonEnd:function(){var e=function(){for(var e=0,n=0,i=l.length;nr&&(h-o)*(r-a)>(d-a)*(t-o)&&++e:d<=r&&(h-o)*(r-a)<(d-a)*(t-o)&&--e;return e}(),n=v&&e,i=(u=Fe(u)).length;(n||i)&&(a.polygonStart(),n&&(a.lineStart(),o(null,null,1,a),a.lineEnd()),i&&Lk(u,s,e,o,a),a.polygonEnd());_=a,u=l=c=null}};function w(t,e){i(t,e)&&_.point(t,e)}function k(o,a){var s=i(o,a);if(l&&c.push([o,a]),y)f=o,h=a,d=s,y=!1,s&&(_.lineStart(),_.point(o,a));else if(s&&m)_.point(o,a);else{var u=[p=Math.max(Vk,Math.min(Gk,p)),g=Math.max(Vk,Math.min(Gk,g))],x=[o=Math.max(Vk,Math.min(Gk,o)),a=Math.max(Vk,Math.min(Gk,a))];!function(t,e,n,r,i,o){var a,s=t[0],u=t[1],l=0,c=1,f=e[0]-s,h=e[1]-u;if(a=n-s,f||!(a>0)){if(a/=f,f<0){if(a0){if(a>c)return;a>l&&(l=a)}if(a=i-s,f||!(a<0)){if(a/=f,f<0){if(a>c)return;a>l&&(l=a)}else if(f>0){if(a0)){if(a/=h,h<0){if(a0){if(a>c)return;a>l&&(l=a)}if(a=o-u,h||!(a<0)){if(a/=h,h<0){if(a>c)return;a>l&&(l=a)}else if(h>0){if(a0&&(t[0]=s+l*f,t[1]=u+l*h),c<1&&(e[0]=s+c*f,e[1]=u+c*h),!0}}}}}(u,x,t,e,n,r)?s&&(_.lineStart(),_.point(o,a),v=!1):(m||(_.lineStart(),_.point(u[0],u[1])),_.point(x[0],x[1]),s||_.lineEnd(),v=!1)}p=o,g=a,m=s}return b}}function Jk(t,e,n){var r=Se(t,e-Lb,n).concat(e);return function(t){return r.map((function(e){return[t,e]}))}}function Zk(t,e,n){var r=Se(t,e-Lb,n).concat(e);return function(t){return r.map((function(e){return[e,t]}))}}var Qk,Kk,tA,eA,nA=t=>t,rA=new se,iA=new se,oA={point:uw,lineStart:uw,lineEnd:uw,polygonStart:function(){oA.lineStart=aA,oA.lineEnd=lA},polygonEnd:function(){oA.lineStart=oA.lineEnd=oA.point=uw,rA.add(Gb(iA)),iA=new se},result:function(){var t=rA/2;return rA=new se,t}};function aA(){oA.point=sA}function sA(t,e){oA.point=uA,Qk=tA=t,Kk=eA=e}function uA(t,e){iA.add(eA*t-tA*e),tA=t,eA=e}function lA(){uA(Qk,Kk)}var cA=1/0,fA=cA,hA=-cA,dA=hA,pA={point:function(t,e){thA&&(hA=t);edA&&(dA=e)},lineStart:uw,lineEnd:uw,polygonStart:uw,polygonEnd:uw,result:function(){var t=[[cA,fA],[hA,dA]];return hA=dA=-(fA=cA=1/0),t}};var gA,mA,yA,vA,_A=0,xA=0,bA=0,wA=0,kA=0,AA=0,MA=0,EA=0,DA=0,CA={point:FA,lineStart:SA,lineEnd:BA,polygonStart:function(){CA.lineStart=zA,CA.lineEnd=NA},polygonEnd:function(){CA.point=FA,CA.lineStart=SA,CA.lineEnd=BA},result:function(){var t=DA?[MA/DA,EA/DA]:AA?[wA/AA,kA/AA]:bA?[_A/bA,xA/bA]:[NaN,NaN];return _A=xA=bA=wA=kA=AA=MA=EA=DA=0,t}};function FA(t,e){_A+=t,xA+=e,++bA}function SA(){CA.point=$A}function $A(t,e){CA.point=TA,FA(yA=t,vA=e)}function TA(t,e){var n=t-yA,r=e-vA,i=iw(n*n+r*r);wA+=i*(yA+t)/2,kA+=i*(vA+e)/2,AA+=i,FA(yA=t,vA=e)}function BA(){CA.point=FA}function zA(){CA.point=OA}function NA(){RA(gA,mA)}function OA(t,e){CA.point=RA,FA(gA=yA=t,mA=vA=e)}function RA(t,e){var n=t-yA,r=e-vA,i=iw(n*n+r*r);wA+=i*(yA+t)/2,kA+=i*(vA+e)/2,AA+=i,MA+=(i=vA*t-yA*e)*(yA+t),EA+=i*(vA+e),DA+=3*i,FA(yA=t,vA=e)}function UA(t){this._context=t}UA.prototype={_radius:4.5,pointRadius:function(t){return this._radius=t,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath(),this._point=NaN},point:function(t,e){switch(this._point){case 0:this._context.moveTo(t,e),this._point=1;break;case 1:this._context.lineTo(t,e);break;default:this._context.moveTo(t+this._radius,e),this._context.arc(t,e,this._radius,0,Wb)}},result:uw};var LA,qA,PA,jA,IA,WA=new se,HA={point:uw,lineStart:function(){HA.point=YA},lineEnd:function(){LA&&GA(qA,PA),HA.point=uw},polygonStart:function(){LA=!0},polygonEnd:function(){LA=null},result:function(){var t=+WA;return WA=new se,t}};function YA(t,e){HA.point=GA,qA=jA=t,PA=IA=e}function GA(t,e){jA-=t,IA-=e,WA.add(iw(jA*jA+IA*IA)),jA=t,IA=e}let VA,XA,JA,ZA;class QA{constructor(t){this._append=null==t?KA:function(t){const e=Math.floor(t);if(!(e>=0))throw new RangeError(`invalid digits: ${t}`);if(e>15)return KA;if(e!==VA){const t=10**e;VA=e,XA=function(e){let n=1;this._+=e[0];for(const r=e.length;n=0))throw new RangeError(`invalid digits: ${t}`);i=e}return null===e&&(r=new QA(i)),a},a.projection(t).digits(i).context(e)}function eM(t){return function(e){var n=new nM;for(var r in t)n[r]=t[r];return n.stream=e,n}}function nM(){}function rM(t,e,n){var r=t.clipExtent&&t.clipExtent();return t.scale(150).translate([0,0]),null!=r&&t.clipExtent(null),pw(n,t.stream(pA)),e(pA.result()),null!=r&&t.clipExtent(r),t}function iM(t,e,n){return rM(t,(function(n){var r=e[1][0]-e[0][0],i=e[1][1]-e[0][1],o=Math.min(r/(n[1][0]-n[0][0]),i/(n[1][1]-n[0][1])),a=+e[0][0]+(r-o*(n[1][0]+n[0][0]))/2,s=+e[0][1]+(i-o*(n[1][1]+n[0][1]))/2;t.scale(150*o).translate([a,s])}),n)}function oM(t,e,n){return iM(t,[[0,0],e],n)}function aM(t,e,n){return rM(t,(function(n){var r=+e,i=r/(n[1][0]-n[0][0]),o=(r-i*(n[1][0]+n[0][0]))/2,a=-i*n[0][1];t.scale(150*i).translate([o,a])}),n)}function sM(t,e,n){return rM(t,(function(n){var r=+e,i=r/(n[1][1]-n[0][1]),o=-i*n[0][0],a=(r-i*(n[1][1]+n[0][1]))/2;t.scale(150*i).translate([o,a])}),n)}nM.prototype={constructor:nM,point:function(t,e){this.stream.point(t,e)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}};var uM=16,lM=Jb(30*Yb);function cM(t,e){return+e?function(t,e){function n(r,i,o,a,s,u,l,c,f,h,d,p,g,m){var y=l-r,v=c-i,_=y*y+v*v;if(_>4*e&&g--){var x=a+h,b=s+d,w=u+p,k=iw(x*x+b*b+w*w),A=sw(w/=k),M=Gb(Gb(w)-1)e||Gb((y*F+v*S)/_-.5)>.3||a*h+s*d+u*p2?t[2]%360*Yb:0,F()):[m*Hb,y*Hb,v*Hb]},D.angle=function(t){return arguments.length?(_=t%360*Yb,F()):_*Hb},D.reflectX=function(t){return arguments.length?(x=t?-1:1,F()):x<0},D.reflectY=function(t){return arguments.length?(b=t?-1:1,F()):b<0},D.precision=function(t){return arguments.length?(a=cM(s,E=t*t),S()):iw(E)},D.fitExtent=function(t,e){return iM(D,t,e)},D.fitSize=function(t,e){return oM(D,t,e)},D.fitWidth=function(t,e){return aM(D,t,e)},D.fitHeight=function(t,e){return sM(D,t,e)},function(){return e=t.apply(this,arguments),D.invert=e.invert&&C,F()}}function gM(t){var e=0,n=Pb/3,r=pM(t),i=r(e,n);return i.parallels=function(t){return arguments.length?r(e=t[0]*Yb,n=t[1]*Yb):[e*Hb,n*Hb]},i}function mM(t,e){var n=nw(t),r=(n+nw(e))/2;if(Gb(r)2?t[2]*Yb:0),e.invert=function(e){return(e=t.invert(e[0]*Yb,e[1]*Yb))[0]*=Hb,e[1]*=Hb,e},e}(i.rotate()).invert([0,0]));return u(null==l?[[s[0]-o,s[1]-o],[s[0]+o,s[1]+o]]:t===kM?[[Math.max(s[0]-o,l),e],[Math.min(s[0]+o,n),r]]:[[l,Math.max(s[1]-o,e)],[n,Math.min(s[1]+o,r)]])}return i.scale=function(t){return arguments.length?(a(t),c()):a()},i.translate=function(t){return arguments.length?(s(t),c()):s()},i.center=function(t){return arguments.length?(o(t),c()):o()},i.clipExtent=function(t){return arguments.length?(null==t?l=e=n=r=null:(l=+t[0][0],e=+t[0][1],n=+t[1][0],r=+t[1][1]),c()):null==l?null:[[l,e],[n,r]]},c()}function MM(t){return ow((jb+t)/2)}function EM(t,e){var n=Jb(t),r=t===e?nw(t):tw(n/Jb(e))/tw(MM(e)/MM(t)),i=n*ew(MM(t),r)/r;if(!r)return kM;function o(t,e){i>0?e<-jb+Lb&&(e=-jb+Lb):e>jb-Lb&&(e=jb-Lb);var n=i/ew(MM(e),r);return[n*nw(r*t),i-n*Jb(r*t)]}return o.invert=function(t,e){var n=i-e,o=rw(r)*iw(t*t+n*n),a=Xb(t,Gb(n))*rw(n);return n*r<0&&(a-=Pb*rw(t)*rw(n)),[a/r,2*Vb(ew(i/o,1/r))-jb]},o}function DM(t,e){return[t,e]}function CM(t,e){var n=Jb(t),r=t===e?nw(t):(n-Jb(e))/(e-t),i=n/r+t;if(Gb(r)Lb&&--i>0);return[t/(.8707+(o=r*r)*(o*(o*o*o*(.003971-.001529*o)-.013791)-.131979)),r]},RM.invert=xM(sw),UM.invert=xM((function(t){return 2*Vb(t)})),LM.invert=function(t,e){return[-e,2*Vb(Qb(t))-jb]};var qM=Math.abs,PM=Math.cos,jM=Math.sin,IM=1e-6,WM=Math.PI,HM=WM/2,YM=function(t){return t>0?Math.sqrt(t):0}(2);function GM(t){return t>1?HM:t<-1?-HM:Math.asin(t)}function VM(t,e){var n,r=t*jM(e),i=30;do{e-=n=(e+jM(e)-r)/(1+PM(e))}while(qM(n)>IM&&--i>0);return e/2}var XM=function(t,e,n){function r(r,i){return[t*r*PM(i=VM(n,i)),e*jM(i)]}return r.invert=function(r,i){return i=GM(i/e),[r/(t*PM(i)),GM((2*i+jM(2*i))/n)]},r}(YM/HM,YM,WM);const JM=tM(),ZM=["clipAngle","clipExtent","scale","translate","center","rotate","parallels","precision","reflectX","reflectY","coefficient","distance","fraction","lobes","parallel","radius","ratio","spacing","tilt"];function QM(t,e){if(!t||"string"!=typeof t)throw new Error("Projection type must be a name string.");return t=t.toLowerCase(),arguments.length>1?(tE[t]=function(t,e){return function n(){const r=e();return r.type=t,r.path=tM().projection(r),r.copy=r.copy||function(){const t=n();return ZM.forEach((e=>{r[e]&&t[e](r[e]())})),t.path.pointRadius(r.path.pointRadius()),t},op(r)}}(t,e),this):tE[t]||null}function KM(t){return t&&t.path||JM}const tE={albers:vM,albersusa:function(){var t,e,n,r,i,o,a=vM(),s=yM().rotate([154,0]).center([-2,58.5]).parallels([55,65]),u=yM().rotate([157,0]).center([-3,19.9]).parallels([8,18]),l={point:function(t,e){o=[t,e]}};function c(t){var e=t[0],a=t[1];return o=null,n.point(e,a),o||(r.point(e,a),o)||(i.point(e,a),o)}function f(){return t=e=null,c}return c.invert=function(t){var e=a.scale(),n=a.translate(),r=(t[0]-n[0])/e,i=(t[1]-n[1])/e;return(i>=.12&&i<.234&&r>=-.425&&r<-.214?s:i>=.166&&i<.234&&r>=-.214&&r<-.115?u:a).invert(t)},c.stream=function(n){return t&&e===n?t:(r=[a.stream(e=n),s.stream(n),u.stream(n)],i=r.length,t={point:function(t,e){for(var n=-1;++n2?t[2]+90:90]):[(t=n())[0],t[1],t[2]-90]},n([0,0,90]).scale(159.155)}};for(const t in tE)QM(t,tE[t]);function eE(){}const nE=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function rE(){var t=1,e=1,n=a;function r(t,e){return e.map((e=>i(t,e)))}function i(r,i){var a=[],s=[];return function(n,r,i){var a,s,u,l,c,f,h=[],d=[];a=s=-1,l=n[0]>=r,nE[l<<1].forEach(p);for(;++a=r,nE[u|l<<1].forEach(p);nE[l<<0].forEach(p);for(;++s=r,c=n[s*t]>=r,nE[l<<1|c<<2].forEach(p);++a=r,f=c,c=n[s*t+a+1]>=r,nE[u|l<<1|c<<2|f<<3].forEach(p);nE[l|c<<3].forEach(p)}a=-1,c=n[s*t]>=r,nE[c<<2].forEach(p);for(;++a=r,nE[c<<2|f<<3].forEach(p);function p(t){var e,n,r=[t[0][0]+a,t[0][1]+s],u=[t[1][0]+a,t[1][1]+s],l=o(r),c=o(u);(e=d[l])?(n=h[c])?(delete d[e.end],delete h[n.start],e===n?(e.ring.push(u),i(e.ring)):h[e.start]=d[n.end]={start:e.start,end:n.end,ring:e.ring.concat(n.ring)}):(delete d[e.end],e.ring.push(u),d[e.end=c]=e):(e=h[c])?(n=d[l])?(delete h[e.start],delete d[n.end],e===n?(e.ring.push(u),i(e.ring)):h[n.start]=d[e.end]={start:n.start,end:e.end,ring:n.ring.concat(e.ring)}):(delete h[e.start],e.ring.unshift(r),h[e.start=l]=e):h[l]=d[c]={start:l,end:c,ring:[r,u]}}nE[c<<3].forEach(p)}(r,i,(t=>{n(t,r,i),function(t){var e=0,n=t.length,r=t[n-1][1]*t[0][0]-t[n-1][0]*t[0][1];for(;++e0?a.push([t]):s.push(t)})),s.forEach((t=>{for(var e,n=0,r=a.length;n{var o,a=n[0],s=n[1],u=0|a,l=0|s,c=r[l*t+u];a>0&&a0&&s=0&&o>=0||s("invalid size"),t=i,e=o,r},r.smooth=function(t){return arguments.length?(n=t?a:eE,r):n===a},r}function iE(t,e){for(var n,r=-1,i=e.length;++rr!=d>r&&n<(h-l)*(r-c)/(d-c)+l&&(i=-i)}return i}function aE(t,e,n){var r,i,o,a;return function(t,e,n){return(e[0]-t[0])*(n[1]-t[1])==(n[0]-t[0])*(e[1]-t[1])}(t,e,n)&&(i=t[r=+(t[0]===e[0])],o=n[r],a=e[r],i<=o&&o<=a||a<=o&&o<=i)}function sE(t,e,n){return function(r){var i=at(r),o=n?Math.min(i[0],0):i[0],a=i[1],s=a-o,u=e?be(o,a,t):s/(t+1);return Se(o+u,a,u)}}function uE(t){Ja.call(this,null,t)}function lE(t,e,n,r,i){const o=t.x1||0,a=t.y1||0,s=e*n<0;function u(t){t.forEach(l)}function l(t){s&&t.reverse(),t.forEach(c)}function c(t){t[0]=(t[0]-o)*e+r,t[1]=(t[1]-a)*n+i}return function(t){return t.coordinates.forEach(u),t}}function cE(t,e,n){const r=t>=0?t:rs(e,n);return Math.round((Math.sqrt(4*r*r+1)-1)/2)}function fE(t){return J(t)?t:rt(+t)}function hE(){var t=t=>t[0],e=t=>t[1],n=d,r=[-1,-1],i=960,o=500,a=2;function u(s,u){const l=cE(r[0],s,t)>>a,c=cE(r[1],s,e)>>a,f=l?l+2:0,h=c?c+2:0,d=2*f+(i>>a),p=2*h+(o>>a),g=new Float32Array(d*p),m=new Float32Array(d*p);let y=g;s.forEach((r=>{const i=f+(+t(r)>>a),o=h+(+e(r)>>a);i>=0&&i=0&&o0&&c>0?(dE(d,p,g,m,l),pE(d,p,m,g,c),dE(d,p,g,m,l),pE(d,p,m,g,c),dE(d,p,g,m,l),pE(d,p,m,g,c)):l>0?(dE(d,p,g,m,l),dE(d,p,m,g,l),dE(d,p,g,m,l),y=m):c>0&&(pE(d,p,g,m,c),pE(d,p,m,g,c),pE(d,p,g,m,c),y=m);const v=u?Math.pow(2,-2*a):1/$e(y);for(let t=0,e=d*p;t>a),y2:h+(o>>a)}}return u.x=function(e){return arguments.length?(t=fE(e),u):t},u.y=function(t){return arguments.length?(e=fE(t),u):e},u.weight=function(t){return arguments.length?(n=fE(t),u):n},u.size=function(t){if(!arguments.length)return[i,o];var e=+t[0],n=+t[1];return e>=0&&n>=0||s("invalid size"),i=e,o=n,u},u.cellSize=function(t){return arguments.length?((t=+t)>=1||s("invalid cell size"),a=Math.floor(Math.log(t)/Math.LN2),u):1<=i&&(e>=o&&(s-=n[e-o+a*t]),r[e-i+a*t]=s/Math.min(e+1,t-1+o-e,o))}function pE(t,e,n,r,i){const o=1+(i<<1);for(let a=0;a=i&&(s>=o&&(u-=n[a+(s-o)*t]),r[a+(s-i)*t]=u/Math.min(s+1,e-1+o-s,o))}function gE(t){Ja.call(this,null,t)}uE.Definition={type:"Isocontour",metadata:{generates:!0},params:[{name:"field",type:"field"},{name:"thresholds",type:"number",array:!0},{name:"levels",type:"number"},{name:"nice",type:"boolean",default:!1},{name:"resolve",type:"enum",values:["shared","independent"],default:"independent"},{name:"zero",type:"boolean",default:!0},{name:"smooth",type:"boolean",default:!0},{name:"scale",type:"number",expr:!0},{name:"translate",type:"number",array:!0,expr:!0},{name:"as",type:"string",null:!0,default:"contour"}]},dt(uE,Ja,{transform(t,e){if(this.value&&!e.changed()&&!t.modified())return e.StopPropagation;var n=e.fork(e.NO_SOURCE|e.NO_FIELDS),r=e.materialize(e.SOURCE).source,i=t.field||f,o=rE().smooth(!1!==t.smooth),a=t.thresholds||function(t,e,n){const r=sE(n.levels||10,n.nice,!1!==n.zero);return"shared"!==n.resolve?r:r(t.map((t=>we(e(t).values))))}(r,i,t),s=null===t.as?null:t.as||"contour",u=[];return r.forEach((e=>{const n=i(e),r=o.size([n.width,n.height])(n.values,k(a)?a:a(n.values));!function(t,e,n,r){let i=r.scale||e.scale,o=r.translate||e.translate;J(i)&&(i=i(n,r));J(o)&&(o=o(n,r));if((1===i||null==i)&&!o)return;const a=(vt(i)?i:i[0])||1,s=(vt(i)?i:i[1])||1,u=o&&o[0]||0,l=o&&o[1]||0;t.forEach(lE(e,a,s,u,l))}(r,n,e,t),r.forEach((t=>{u.push(ba(e,_a(null!=s?{[s]:t}:t)))}))})),this.value&&(n.rem=this.value),this.value=n.source=n.add=u,n}}),gE.Definition={type:"KDE2D",metadata:{generates:!0},params:[{name:"size",type:"number",array:!0,length:2,required:!0},{name:"x",type:"field",required:!0},{name:"y",type:"field",required:!0},{name:"weight",type:"field"},{name:"groupby",type:"field",array:!0},{name:"cellSize",type:"number"},{name:"bandwidth",type:"number",array:!0,length:2},{name:"counts",type:"boolean",default:!1},{name:"as",type:"string",default:"grid"}]};const mE=["x","y","weight","size","cellSize","bandwidth"];function yE(t,e){return mE.forEach((n=>null!=e[n]?t[n](e[n]):0)),t}function vE(t){Ja.call(this,null,t)}dt(gE,Ja,{transform(t,e){if(this.value&&!e.changed()&&!t.modified())return e.StopPropagation;var r,i=e.fork(e.NO_SOURCE|e.NO_FIELDS),o=function(t,e){var n,r,i,o,a,s,u=[],l=t=>t(o);if(null==e)u.push(t);else for(n={},r=0,i=t.length;r_a(function(t,e){for(let n=0;nLb})).map(u)).concat(Se(Zb(o/d)*d,i,d).filter((function(t){return Gb(t%g)>Lb})).map(l))}return y.lines=function(){return v().map((function(t){return{type:"LineString",coordinates:t}}))},y.outline=function(){return{type:"Polygon",coordinates:[c(r).concat(f(a).slice(1),c(n).reverse().slice(1),f(s).reverse().slice(1))]}},y.extent=function(t){return arguments.length?y.extentMajor(t).extentMinor(t):y.extentMinor()},y.extentMajor=function(t){return arguments.length?(r=+t[0][0],n=+t[1][0],s=+t[0][1],a=+t[1][1],r>n&&(t=r,r=n,n=t),s>a&&(t=s,s=a,a=t),y.precision(m)):[[r,s],[n,a]]},y.extentMinor=function(n){return arguments.length?(e=+n[0][0],t=+n[1][0],o=+n[0][1],i=+n[1][1],e>t&&(n=e,e=t,t=n),o>i&&(n=o,o=i,i=n),y.precision(m)):[[e,o],[t,i]]},y.step=function(t){return arguments.length?y.stepMajor(t).stepMinor(t):y.stepMinor()},y.stepMajor=function(t){return arguments.length?(p=+t[0],g=+t[1],y):[p,g]},y.stepMinor=function(t){return arguments.length?(h=+t[0],d=+t[1],y):[h,d]},y.precision=function(h){return arguments.length?(m=+h,u=Jk(o,i,90),l=Zk(e,t,m),c=Jk(s,a,90),f=Zk(r,n,m),y):m},y.extentMajor([[-180,-90+Lb],[180,90-Lb]]).extentMinor([[-180,-80-Lb],[180,80+Lb]])}()}function EE(t){Ja.call(this,null,t)}function DE(t){if(!J(t))return!1;const e=Bt(r(t));return e.$x||e.$y||e.$value||e.$max}function CE(t){Ja.call(this,null,t),this.modified(!0)}function FE(t,e,n){J(t[e])&&t[e](n)}bE.Definition={type:"GeoJSON",metadata:{},params:[{name:"fields",type:"field",array:!0,length:2},{name:"geojson",type:"field"}]},dt(bE,Ja,{transform(t,e){var n,i=this._features,o=this._points,a=t.fields,s=a&&a[0],u=a&&a[1],l=t.geojson||!a&&f,c=e.ADD;n=t.modified()||e.changed(e.REM)||e.modified(r(l))||s&&e.modified(r(s))||u&&e.modified(r(u)),this.value&&!n||(c=e.SOURCE,this._features=i=[],this._points=o=[]),l&&e.visit(c,(t=>i.push(l(t)))),s&&u&&(e.visit(c,(t=>{var e=s(t),n=u(t);null!=e&&null!=n&&(e=+e)===e&&(n=+n)===n&&o.push([e,n])})),i=i.concat({type:_E,geometry:{type:"MultiPoint",coordinates:o}})),this.value={type:xE,features:i}}}),wE.Definition={type:"GeoPath",metadata:{modifies:!0},params:[{name:"projection",type:"projection"},{name:"field",type:"field"},{name:"pointRadius",type:"number",expr:!0},{name:"as",type:"string",default:"path"}]},dt(wE,Ja,{transform(t,e){var n=e.fork(e.ALL),r=this.value,i=t.field||f,o=t.as||"path",a=n.SOURCE;!r||t.modified()?(this.value=r=KM(t.projection),n.materialize().reflow()):a=i===f||e.modified(i.fields)?n.ADD_MOD:n.ADD;const s=function(t,e){const n=t.pointRadius();t.context(null),null!=e&&t.pointRadius(e);return n}(r,t.pointRadius);return n.visit(a,(t=>t[o]=r(i(t)))),r.pointRadius(s),n.modifies(o)}}),kE.Definition={type:"GeoPoint",metadata:{modifies:!0},params:[{name:"projection",type:"projection",required:!0},{name:"fields",type:"field",array:!0,required:!0,length:2},{name:"as",type:"string",array:!0,length:2,default:["x","y"]}]},dt(kE,Ja,{transform(t,e){var n,r=t.projection,i=t.fields[0],o=t.fields[1],a=t.as||["x","y"],s=a[0],u=a[1];function l(t){const e=r([i(t),o(t)]);e?(t[s]=e[0],t[u]=e[1]):(t[s]=void 0,t[u]=void 0)}return t.modified()?e=e.materialize().reflow(!0).visit(e.SOURCE,l):(n=e.modified(i.fields)||e.modified(o.fields),e.visit(n?e.ADD_MOD:e.ADD,l)),e.modifies(a)}}),AE.Definition={type:"GeoShape",metadata:{modifies:!0,nomod:!0},params:[{name:"projection",type:"projection"},{name:"field",type:"field",default:"datum"},{name:"pointRadius",type:"number",expr:!0},{name:"as",type:"string",default:"shape"}]},dt(AE,Ja,{transform(t,e){var n=e.fork(e.ALL),r=this.value,i=t.as||"shape",o=n.ADD;return r&&!t.modified()||(this.value=r=function(t,e,n){const r=null==n?n=>t(e(n)):r=>{var i=t.pointRadius(),o=t.pointRadius(n)(e(r));return t.pointRadius(i),o};return r.context=e=>(t.context(e),r),r}(KM(t.projection),t.field||l("datum"),t.pointRadius),n.materialize().reflow(),o=n.SOURCE),n.visit(o,(t=>t[i]=r)),n.modifies(i)}}),ME.Definition={type:"Graticule",metadata:{changes:!0,generates:!0},params:[{name:"extent",type:"array",array:!0,length:2,content:{type:"number",array:!0,length:2}},{name:"extentMajor",type:"array",array:!0,length:2,content:{type:"number",array:!0,length:2}},{name:"extentMinor",type:"array",array:!0,length:2,content:{type:"number",array:!0,length:2}},{name:"step",type:"number",array:!0,length:2},{name:"stepMajor",type:"number",array:!0,length:2,default:[90,360]},{name:"stepMinor",type:"number",array:!0,length:2,default:[10,10]},{name:"precision",type:"number",default:2.5}]},dt(ME,Ja,{transform(t,e){var n,r=this.value,i=this.generator;if(!r.length||t.modified())for(const e in t)J(i[e])&&i[e](t[e]);return n=i(),r.length?e.mod.push(wa(r[0],n)):e.add.push(_a(n)),r[0]=n,e}}),EE.Definition={type:"heatmap",metadata:{modifies:!0},params:[{name:"field",type:"field"},{name:"color",type:"string",expr:!0},{name:"opacity",type:"number",expr:!0},{name:"resolve",type:"enum",values:["shared","independent"],default:"independent"},{name:"as",type:"string",default:"image"}]},dt(EE,Ja,{transform(t,e){if(!e.changed()&&!t.modified())return e.StopPropagation;var n=e.materialize(e.SOURCE).source,r="shared"===t.resolve,i=t.field||f,o=function(t,e){let n;J(t)?(n=n=>t(n,e),n.dep=DE(t)):t?n=rt(t):(n=t=>t.$value/t.$max||0,n.dep=!0);return n}(t.opacity,t),a=function(t,e){let n;J(t)?(n=n=>af(t(n,e)),n.dep=DE(t)):n=rt(af(t||"#888"));return n}(t.color,t),s=t.as||"image",u={$x:0,$y:0,$value:0,$max:r?we(n.map((t=>we(i(t).values)))):0};return n.forEach((t=>{const e=i(t),n=ot({},t,u);r||(n.$max=we(e.values||[])),t[s]=function(t,e,n,r){const i=t.width,o=t.height,a=t.x1||0,s=t.y1||0,u=t.x2||i,l=t.y2||o,c=t.values,f=c?t=>c[t]:h,d=$c(u-a,l-s),p=d.getContext("2d"),g=p.getImageData(0,0,u-a,l-s),m=g.data;for(let t=s,o=0;t{null!=t[e]&&FE(n,e,t[e])}))):ZM.forEach((e=>{t.modified(e)&&FE(n,e,t[e])})),null!=t.pointRadius&&n.path.pointRadius(t.pointRadius),t.fit&&function(t,e){const n=function(t){return t=V(t),1===t.length?t[0]:{type:xE,features:t.reduce(((t,e)=>t.concat(function(t){return t.type===xE?t.features:V(t).filter((t=>null!=t)).map((t=>t.type===_E?t:{type:_E,geometry:t}))}(e))),[])}}(e.fit);e.extent?t.fitExtent(e.extent,n):e.size&&t.fitSize(e.size,n)}(n,t),e.fork(e.NO_SOURCE|e.NO_FIELDS)}});var SE=Object.freeze({__proto__:null,contour:vE,geojson:bE,geopath:wE,geopoint:kE,geoshape:AE,graticule:ME,heatmap:EE,isocontour:uE,kde2d:gE,projection:CE});function $E(t,e,n,r){if(isNaN(e)||isNaN(n))return t;var i,o,a,s,u,l,c,f,h,d=t._root,p={data:r},g=t._x0,m=t._y0,y=t._x1,v=t._y1;if(!d)return t._root=p,t;for(;d.length;)if((l=e>=(o=(g+y)/2))?g=o:y=o,(c=n>=(a=(m+v)/2))?m=a:v=a,i=d,!(d=d[f=c<<1|l]))return i[f]=p,t;if(s=+t._x.call(null,d.data),u=+t._y.call(null,d.data),e===s&&n===u)return p.next=d,i?i[f]=p:t._root=p,t;do{i=i?i[f]=new Array(4):t._root=new Array(4),(l=e>=(o=(g+y)/2))?g=o:y=o,(c=n>=(a=(m+v)/2))?m=a:v=a}while((f=c<<1|l)==(h=(u>=a)<<1|s>=o));return i[h]=d,i[f]=p,t}function TE(t,e,n,r,i){this.node=t,this.x0=e,this.y0=n,this.x1=r,this.y1=i}function BE(t){return t[0]}function zE(t){return t[1]}function NE(t,e,n){var r=new OE(null==e?BE:e,null==n?zE:n,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function OE(t,e,n,r,i,o){this._x=t,this._y=e,this._x0=n,this._y0=r,this._x1=i,this._y1=o,this._root=void 0}function RE(t){for(var e={data:t.data},n=e;t=t.next;)n=n.next={data:t.data};return e}var UE=NE.prototype=OE.prototype;function LE(t){return function(){return t}}function qE(t){return 1e-6*(t()-.5)}function PE(t){return t.x+t.vx}function jE(t){return t.y+t.vy}function IE(t){return t.index}function WE(t,e){var n=t.get(e);if(!n)throw new Error("node not found: "+e);return n}UE.copy=function(){var t,e,n=new OE(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return n;if(!r.length)return n._root=RE(r),n;for(t=[{source:r,target:n._root=new Array(4)}];r=t.pop();)for(var i=0;i<4;++i)(e=r.source[i])&&(e.length?t.push({source:e,target:r.target[i]=new Array(4)}):r.target[i]=RE(e));return n},UE.add=function(t){const e=+this._x.call(null,t),n=+this._y.call(null,t);return $E(this.cover(e,n),e,n,t)},UE.addAll=function(t){var e,n,r,i,o=t.length,a=new Array(o),s=new Array(o),u=1/0,l=1/0,c=-1/0,f=-1/0;for(n=0;nc&&(c=r),if&&(f=i));if(u>c||l>f)return this;for(this.cover(u,l).cover(c,f),n=0;nt||t>=i||r>e||e>=o;)switch(s=(eh||(o=u.y0)>d||(a=u.x1)=y)<<1|t>=m)&&(u=p[p.length-1],p[p.length-1]=p[p.length-1-l],p[p.length-1-l]=u)}else{var v=t-+this._x.call(null,g.data),_=e-+this._y.call(null,g.data),x=v*v+_*_;if(x=(s=(p+m)/2))?p=s:m=s,(c=a>=(u=(g+y)/2))?g=u:y=u,e=d,!(d=d[f=c<<1|l]))return this;if(!d.length)break;(e[f+1&3]||e[f+2&3]||e[f+3&3])&&(n=e,h=f)}for(;d.data!==t;)if(r=d,!(d=d.next))return this;return(i=d.next)&&delete d.next,r?(i?r.next=i:delete r.next,this):e?(i?e[f]=i:delete e[f],(d=e[0]||e[1]||e[2]||e[3])&&d===(e[3]||e[2]||e[1]||e[0])&&!d.length&&(n?n[h]=d:this._root=d),this):(this._root=i,this)},UE.removeAll=function(t){for(var e=0,n=t.length;e{}};function YE(){for(var t,e=0,n=arguments.length,r={};e=0&&(e=t.slice(n+1),t=t.slice(0,n)),t&&!r.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:e}}))),a=-1,s=o.length;if(!(arguments.length<2)){if(null!=e&&"function"!=typeof e)throw new Error("invalid callback: "+e);for(;++a0)for(var n,r,i=new Array(n),o=0;o=0&&e._call.call(void 0,t),e=e._next;--QE}()}finally{QE=0,function(){var t,e,n=JE,r=1/0;for(;n;)n._call?(r>n._time&&(r=n._time),t=n,n=n._next):(e=n._next,n._next=null,n=t?t._next=e:JE=e);ZE=t,dD(r)}(),rD=0}}function hD(){var t=oD.now(),e=t-nD;e>eD&&(iD-=e,nD=t)}function dD(t){QE||(KE&&(KE=clearTimeout(KE)),t-rD>24?(t<1/0&&(KE=setTimeout(fD,t-oD.now()-iD)),tD&&(tD=clearInterval(tD))):(tD||(nD=oD.now(),tD=setInterval(hD,eD)),QE=1,aD(fD)))}lD.prototype=cD.prototype={constructor:lD,restart:function(t,e,n){if("function"!=typeof t)throw new TypeError("callback is not a function");n=(null==n?sD():+n)+(null==e?0:+e),this._next||ZE===this||(ZE?ZE._next=this:JE=this,ZE=this),this._call=t,this._time=n,dD()},stop:function(){this._call&&(this._call=null,this._time=1/0,dD())}};const pD=1664525,gD=1013904223,mD=4294967296;function yD(t){return t.x}function vD(t){return t.y}var _D=10,xD=Math.PI*(3-Math.sqrt(5));function bD(t){var e,n=1,r=.001,i=1-Math.pow(r,1/300),o=0,a=.6,s=new Map,u=cD(f),l=YE("tick","end"),c=function(){let t=1;return()=>(t=(pD*t+gD)%mD)/mD}();function f(){h(),l.call("tick",e),n1?(null==n?s.delete(t):s.set(t,p(n)),e):s.get(t)},find:function(e,n,r){var i,o,a,s,u,l=0,c=t.length;for(null==r?r=1/0:r*=r,l=0;l1?(l.on(t,n),e):l.on(t)}}}const wD={center:function(t,e){var n,r=1;function i(){var i,o,a=n.length,s=0,u=0;for(i=0;il+p||oc+p||au.index){var g=l-s.x-s.vx,m=c-s.y-s.vy,y=g*g+m*m;yt.r&&(t.r=t[e].r)}function u(){if(e){var r,i,o=e.length;for(n=new Array(o),r=0;r=s)){(t.data!==e||t.next)&&(0===f&&(p+=(f=qE(n))*f),0===h&&(p+=(h=qE(n))*h),p[s(t,e,r),t])));for(a=0,i=new Array(l);ae(t,n):e)}DD.Definition={type:"Force",metadata:{modifies:!0},params:[{name:"static",type:"boolean",default:!1},{name:"restart",type:"boolean",default:!1},{name:"iterations",type:"number",default:300},{name:"alpha",type:"number",default:1},{name:"alphaMin",type:"number",default:.001},{name:"alphaTarget",type:"number",default:0},{name:"velocityDecay",type:"number",default:.4},{name:"forces",type:"param",array:!0,params:[{key:{force:"center"},params:[{name:"x",type:"number",default:0},{name:"y",type:"number",default:0}]},{key:{force:"collide"},params:[{name:"radius",type:"number",expr:!0},{name:"strength",type:"number",default:.7},{name:"iterations",type:"number",default:1}]},{key:{force:"nbody"},params:[{name:"strength",type:"number",default:-30,expr:!0},{name:"theta",type:"number",default:.9},{name:"distanceMin",type:"number",default:1},{name:"distanceMax",type:"number"}]},{key:{force:"link"},params:[{name:"links",type:"data"},{name:"id",type:"field"},{name:"distance",type:"number",default:30,expr:!0},{name:"strength",type:"number",expr:!0},{name:"iterations",type:"number",default:1}]},{key:{force:"x"},params:[{name:"strength",type:"number",default:.1},{name:"x",type:"field"}]},{key:{force:"y"},params:[{name:"strength",type:"number",default:.1},{name:"y",type:"field"}]}]},{name:"as",type:"string",array:!0,modify:!1,default:ED}]},dt(DD,Ja,{transform(t,e){var n,r,i=this.value,o=e.changed(e.ADD_REM),a=t.modified(AD),s=t.iterations||300;if(i?(o&&(e.modifies("index"),i.nodes(e.source)),(a||e.changed(e.MOD))&&CD(i,t,0,e)):(this.value=i=function(t,e){const n=bD(t),r=n.stop,i=n.restart;let o=!1;return n.stopped=()=>o,n.restart=()=>(o=!1,i()),n.stop=()=>(o=!0,r()),CD(n,e,!0).on("end",(()=>o=!0))}(e.source,t),i.on("tick",(n=e.dataflow,r=this,()=>n.touch(r).run())),t.static||(o=!0,i.tick()),e.modifies("index")),a||o||t.modified(MD)||e.changed()&&t.restart)if(i.alpha(Math.max(i.alpha(),t.alpha||1)).alphaDecay(1-Math.pow(i.alphaMin(),1/s)),t.static)for(i.stop();--s>=0;)i.tick();else if(i.stopped()&&i.restart(),!o)return e.StopPropagation;return this.finish(t,e)},finish(t,e){const n=e.dataflow;for(let t,e=this._argops,s=0,u=e.length;s=0;)e+=n[r].value;else e=1;t.value=e}function RD(t,e){t instanceof Map?(t=[void 0,t],void 0===e&&(e=LD)):void 0===e&&(e=UD);for(var n,r,i,o,a,s=new jD(t),u=[s];n=u.pop();)if((i=e(n.data))&&(a=(i=Array.from(i)).length))for(n.children=i,o=a-1;o>=0;--o)u.push(r=i[o]=new jD(i[o])),r.parent=n,r.depth=n.depth+1;return s.eachBefore(PD)}function UD(t){return t.children}function LD(t){return Array.isArray(t)?t[1]:null}function qD(t){void 0!==t.data.value&&(t.value=t.data.value),t.data=t.data.data}function PD(t){var e=0;do{t.height=e}while((t=t.parent)&&t.height<++e)}function jD(t){this.data=t,this.depth=this.height=0,this.parent=null}function ID(t){return null==t?null:WD(t)}function WD(t){if("function"!=typeof t)throw new Error;return t}function HD(){return 0}function YD(t){return function(){return t}}jD.prototype=RD.prototype={constructor:jD,count:function(){return this.eachAfter(OD)},each:function(t,e){let n=-1;for(const r of this)t.call(e,r,++n,this);return this},eachAfter:function(t,e){for(var n,r,i,o=this,a=[o],s=[],u=-1;o=a.pop();)if(s.push(o),n=o.children)for(r=0,i=n.length;r=0;--r)o.push(n[r]);return this},find:function(t,e){let n=-1;for(const r of this)if(t.call(e,r,++n,this))return r},sum:function(t){return this.eachAfter((function(e){for(var n=+t(e.data)||0,r=e.children,i=r&&r.length;--i>=0;)n+=r[i].value;e.value=n}))},sort:function(t){return this.eachBefore((function(e){e.children&&e.children.sort(t)}))},path:function(t){for(var e=this,n=function(t,e){if(t===e)return t;var n=t.ancestors(),r=e.ancestors(),i=null;t=n.pop(),e=r.pop();for(;t===e;)i=t,t=n.pop(),e=r.pop();return i}(e,t),r=[e];e!==n;)e=e.parent,r.push(e);for(var i=r.length;t!==n;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e},descendants:function(){return Array.from(this)},leaves:function(){var t=[];return this.eachBefore((function(e){e.children||t.push(e)})),t},links:function(){var t=this,e=[];return t.each((function(n){n!==t&&e.push({source:n.parent,target:n})})),e},copy:function(){return RD(this).eachBefore(qD)},[Symbol.iterator]:function*(){var t,e,n,r,i=this,o=[i];do{for(t=o.reverse(),o=[];i=t.pop();)if(yield i,e=i.children)for(n=0,r=e.length;n0&&n*n>r*r+i*i}function KD(t,e){for(var n=0;n1e-6?(D+Math.sqrt(D*D-4*E*C))/(2*E):C/D);return{x:r+w+k*F,y:i+A+M*F,r:F}}function rC(t,e,n){var r,i,o,a,s=t.x-e.x,u=t.y-e.y,l=s*s+u*u;l?(i=e.r+n.r,i*=i,a=t.r+n.r,i>(a*=a)?(r=(l+a-i)/(2*l),o=Math.sqrt(Math.max(0,a/l-r*r)),n.x=t.x-r*s-o*u,n.y=t.y-r*u+o*s):(r=(l+i-a)/(2*l),o=Math.sqrt(Math.max(0,i/l-r*r)),n.x=e.x+r*s-o*u,n.y=e.y+r*u+o*s)):(n.x=e.x+n.r,n.y=e.y)}function iC(t,e){var n=t.r+e.r-1e-6,r=e.x-t.x,i=e.y-t.y;return n>0&&n*n>r*r+i*i}function oC(t){var e=t._,n=t.next._,r=e.r+n.r,i=(e.x*n.r+n.x*e.r)/r,o=(e.y*n.r+n.y*e.r)/r;return i*i+o*o}function aC(t){this._=t,this.next=null,this.previous=null}function sC(t,e){if(!(o=(t=function(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}(t)).length))return 0;var n,r,i,o,a,s,u,l,c,f,h;if((n=t[0]).x=0,n.y=0,!(o>1))return n.r;if(r=t[1],n.x=-r.r,r.x=n.r,r.y=0,!(o>2))return n.r+r.r;rC(r,n,i=t[2]),n=new aC(n),r=new aC(r),i=new aC(i),n.next=i.previous=r,r.next=n.previous=i,i.next=r.previous=n;t:for(u=3;ufunction(t){t=`${t}`;let e=t.length;bC(t,e-1)&&!bC(t,e-2)&&(t=t.slice(0,-1));return"/"===t[0]?t:`/${t}`}(t(e,n,r)))),n=e.map(xC),i=new Set(e).add("");for(const t of n)i.has(t)||(i.add(t),e.push(t),n.push(xC(t)),h.push(mC));d=(t,n)=>e[n],p=(t,e)=>n[e]}for(a=0,i=h.length;a=0&&(l=h[t]).data===mC;--t)l.data=null}if(s.parent=pC,s.eachBefore((function(t){t.depth=t.parent.depth+1,--i})).eachBefore(PD),s.parent=null,i>0)throw new Error("cycle");return s}return r.id=function(t){return arguments.length?(e=ID(t),r):e},r.parentId=function(t){return arguments.length?(n=ID(t),r):n},r.path=function(e){return arguments.length?(t=ID(e),r):t},r}function xC(t){let e=t.length;if(e<2)return"";for(;--e>1&&!bC(t,e););return t.slice(0,e)}function bC(t,e){if("/"===t[e]){let n=0;for(;e>0&&"\\"===t[--e];)++n;if(0==(1&n))return!0}return!1}function wC(t,e){return t.parent===e.parent?1:2}function kC(t){var e=t.children;return e?e[0]:t.t}function AC(t){var e=t.children;return e?e[e.length-1]:t.t}function MC(t,e,n){var r=n/(e.i-t.i);e.c-=r,e.s+=n,t.c+=r,e.z+=n,e.m+=n}function EC(t,e,n){return t.a.parent===e.parent?t.a:n}function DC(t,e){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=e}function CC(t,e,n,r,i){for(var o,a=t.children,s=-1,u=a.length,l=t.value&&(i-n)/t.value;++sh&&(h=s),m=c*c*g,(d=Math.max(h/m,m/f))>p){c-=s;break}p=d}y.push(a={value:c,dice:u1?e:1)},n}(FC);var TC=function t(e){function n(t,n,r,i,o){if((a=t._squarify)&&a.ratio===e)for(var a,s,u,l,c,f=-1,h=a.length,d=t.value;++f1?e:1)},n}(FC);function BC(t,e,n){const r={};return t.each((t=>{const i=t.data;n(i)&&(r[e(i)]=t)})),t.lookup=r,t}function zC(t){Ja.call(this,null,t)}zC.Definition={type:"Nest",metadata:{treesource:!0,changes:!0},params:[{name:"keys",type:"field",array:!0},{name:"generate",type:"boolean"}]};const NC=t=>t.values;function OC(){const t=[],e={entries:t=>r(n(t,0),0),key:n=>(t.push(n),e)};function n(e,r){if(r>=t.length)return e;const i=e.length,o=t[r++],a={},s={};let u,l,c,f=-1;for(;++ft.length)return e;const i=[];for(const t in e)i.push({key:t,values:r(e[t],n)});return i}return e}function RC(t){Ja.call(this,null,t)}dt(zC,Ja,{transform(t,e){e.source||s("Nest transform requires an upstream data source.");var n=t.generate,r=t.modified(),i=e.clone(),o=this.value;return(!o||r||e.changed())&&(o&&o.each((t=>{t.children&&ma(t.data)&&i.rem.push(t.data)})),this.value=o=RD({values:V(t.keys).reduce(((t,e)=>(t.key(e),t)),OC()).entries(i.source)},NC),n&&o.each((t=>{t.children&&(t=_a(t.data),i.add.push(t),i.source.push(t))})),BC(o,ya,ya)),i.source.root=o,i}});const UC=(t,e)=>t.parent===e.parent?1:2;dt(RC,Ja,{transform(t,e){e.source&&e.source.root||s(this.constructor.name+" transform requires a backing tree data source.");const n=this.layout(t.method),r=this.fields,i=e.source.root,o=t.as||r;t.field?i.sum(t.field):i.count(),t.sort&&i.sort(ka(t.sort,(t=>t.data))),function(t,e,n){for(let r,i=0,o=e.length;ifunction(t,e,n){const r=t.data,i=e.length-1;for(let o=0;o(t=(GD*t+VD)%XD)/XD}();return i.x=e/2,i.y=n/2,t?i.eachBefore(lC(t)).eachAfter(cC(r,.5,o)).eachBefore(fC(1)):i.eachBefore(lC(uC)).eachAfter(cC(HD,1,o)).eachAfter(cC(r,i.r/Math.min(e,n),o)).eachBefore(fC(Math.min(e,n)/(2*i.r))),i}return i.radius=function(e){return arguments.length?(t=ID(e),i):t},i.size=function(t){return arguments.length?(e=+t[0],n=+t[1],i):[e,n]},i.padding=function(t){return arguments.length?(r="function"==typeof t?t:YD(+t),i):r},i},params:["radius","size","padding"],fields:LC});const PC=["x0","y0","x1","y1","depth","children"];function jC(t){RC.call(this,t)}function IC(t){Ja.call(this,null,t)}jC.Definition={type:"Partition",metadata:{tree:!0,modifies:!0},params:[{name:"field",type:"field"},{name:"sort",type:"compare"},{name:"padding",type:"number",default:0},{name:"round",type:"boolean",default:!1},{name:"size",type:"number",array:!0,length:2},{name:"as",type:"string",array:!0,length:PC.length,default:PC}]},dt(jC,RC,{layout:function(){var t=1,e=1,n=0,r=!1;function i(i){var o=i.height+1;return i.x0=i.y0=n,i.x1=t,i.y1=e/o,i.eachBefore(function(t,e){return function(r){r.children&&dC(r,r.x0,t*(r.depth+1)/e,r.x1,t*(r.depth+2)/e);var i=r.x0,o=r.y0,a=r.x1-n,s=r.y1-n;a=0;--i)s.push(n=e.children[i]=new DC(r[i],i)),n.parent=e;return(a.parent=new DC(null,0)).children=[a],a}(i);if(u.eachAfter(o),u.parent.m=-u.z,u.eachBefore(a),r)i.eachBefore(s);else{var l=i,c=i,f=i;i.eachBefore((function(t){t.xc.x&&(c=t),t.depth>f.depth&&(f=t)}));var h=l===c?1:t(l,c)/2,d=h-l.x,p=e/(c.x+h+d),g=n/(f.depth||1);i.eachBefore((function(t){t.x=(t.x+d)*p,t.y=t.depth*g}))}return i}function o(e){var n=e.children,r=e.parent.children,i=e.i?r[e.i-1]:null;if(n){!function(t){for(var e,n=0,r=0,i=t.children,o=i.length;--o>=0;)(e=i[o]).z+=n,e.m+=n,n+=e.s+(r+=e.c)}(e);var o=(n[0].z+n[n.length-1].z)/2;i?(e.z=i.z+t(e._,i._),e.m=e.z-o):e.z=o}else i&&(e.z=i.z+t(e._,i._));e.parent.A=function(e,n,r){if(n){for(var i,o=e,a=e,s=n,u=o.parent.children[0],l=o.m,c=a.m,f=s.m,h=u.m;s=AC(s),o=kC(o),s&&o;)u=kC(u),(a=AC(a)).a=e,(i=s.z+f-o.z-l+t(s._,o._))>0&&(MC(EC(s,e,r),e,i),l+=i,c+=i),f+=s.m,l+=o.m,h+=u.m,c+=a.m;s&&!AC(a)&&(a.t=s,a.m+=f-c),o&&!kC(u)&&(u.t=o,u.m+=l-h,r=e)}return r}(e,i,e.parent.A||r[0])}function a(t){t._.x=t.z+t.parent.m,t.m+=t.parent.m}function s(t){t.x*=e,t.y=t.depth*n}return i.separation=function(e){return arguments.length?(t=e,i):t},i.size=function(t){return arguments.length?(r=!1,e=+t[0],n=+t[1],i):r?null:[e,n]},i.nodeSize=function(t){return arguments.length?(r=!0,e=+t[0],n=+t[1],i):r?[e,n]:null},i},cluster:function(){var t=BD,e=1,n=1,r=!1;function i(i){var o,a=0;i.eachAfter((function(e){var n=e.children;n?(e.x=function(t){return t.reduce(zD,0)/t.length}(n),e.y=function(t){return 1+t.reduce(ND,0)}(n)):(e.x=o?a+=t(e,o):0,e.y=0,o=e)}));var s=function(t){for(var e;e=t.children;)t=e[0];return t}(i),u=function(t){for(var e;e=t.children;)t=e[e.length-1];return t}(i),l=s.x-t(s,u)/2,c=u.x+t(u,s)/2;return i.eachAfter(r?function(t){t.x=(t.x-i.x)*e,t.y=(i.y-t.y)*n}:function(t){t.x=(t.x-l)/(c-l)*e,t.y=(1-(i.y?t.y/i.y:1))*n})}return i.separation=function(e){return arguments.length?(t=e,i):t},i.size=function(t){return arguments.length?(r=!1,e=+t[0],n=+t[1],i):r?null:[e,n]},i.nodeSize=function(t){return arguments.length?(r=!0,e=+t[0],n=+t[1],i):r?[e,n]:null},i}},HC=["x","y","depth","children"];function YC(t){RC.call(this,t)}function GC(t){Ja.call(this,[],t)}YC.Definition={type:"Tree",metadata:{tree:!0,modifies:!0},params:[{name:"field",type:"field"},{name:"sort",type:"compare"},{name:"method",type:"enum",default:"tidy",values:["tidy","cluster"]},{name:"size",type:"number",array:!0,length:2},{name:"nodeSize",type:"number",array:!0,length:2},{name:"separation",type:"boolean",default:!0},{name:"as",type:"string",array:!0,length:HC.length,default:HC}]},dt(YC,RC,{layout(t){const e=t||"tidy";if(lt(WC,e))return WC[e]();s("Unrecognized Tree layout method: "+e)},params:["size","nodeSize"],fields:HC}),GC.Definition={type:"TreeLinks",metadata:{tree:!0,generates:!0,changes:!0},params:[]},dt(GC,Ja,{transform(t,e){const n=this.value,r=e.source&&e.source.root,i=e.fork(e.NO_SOURCE),o={};return r||s("TreeLinks transform requires a tree data source."),e.changed(e.ADD_REM)?(i.rem=n,e.visit(e.SOURCE,(t=>o[ya(t)]=1)),r.each((t=>{const e=t.data,n=t.parent&&t.parent.data;n&&o[ya(e)]&&o[ya(n)]&&i.add.push(_a({source:n,target:e}))})),this.value=i.add):e.changed(e.MOD)&&(e.visit(e.MOD,(t=>o[ya(t)]=1)),n.forEach((t=>{(o[ya(t.source)]||o[ya(t.target)])&&i.mod.push(t)}))),i}});const VC={binary:function(t,e,n,r,i){var o,a,s=t.children,u=s.length,l=new Array(u+1);for(l[0]=a=o=0;o=n-1){var c=s[e];return c.x0=i,c.y0=o,c.x1=a,void(c.y1=u)}var f=l[e],h=r/2+f,d=e+1,p=n-1;for(;d>>1;l[g]u-o){var v=r?(i*y+a*m)/r:a;t(e,d,m,i,o,v,u),t(d,n,y,v,o,a,u)}else{var _=r?(o*y+u*m)/r:u;t(e,d,m,i,o,a,_),t(d,n,y,i,_,a,u)}}(0,u,t.value,e,n,r,i)},dice:dC,slice:CC,slicedice:function(t,e,n,r,i){(1&t.depth?CC:dC)(t,e,n,r,i)},squarify:$C,resquarify:TC},XC=["x0","y0","x1","y1","depth","children"];function JC(t){RC.call(this,t)}JC.Definition={type:"Treemap",metadata:{tree:!0,modifies:!0},params:[{name:"field",type:"field"},{name:"sort",type:"compare"},{name:"method",type:"enum",default:"squarify",values:["squarify","resquarify","binary","dice","slice","slicedice"]},{name:"padding",type:"number",default:0},{name:"paddingInner",type:"number",default:0},{name:"paddingOuter",type:"number",default:0},{name:"paddingTop",type:"number",default:0},{name:"paddingRight",type:"number",default:0},{name:"paddingBottom",type:"number",default:0},{name:"paddingLeft",type:"number",default:0},{name:"ratio",type:"number",default:1.618033988749895},{name:"round",type:"boolean",default:!1},{name:"size",type:"number",array:!0,length:2},{name:"as",type:"string",array:!0,length:XC.length,default:XC}]},dt(JC,RC,{layout(){const t=function(){var t=$C,e=!1,n=1,r=1,i=[0],o=HD,a=HD,s=HD,u=HD,l=HD;function c(t){return t.x0=t.y0=0,t.x1=n,t.y1=r,t.eachBefore(f),i=[0],e&&t.eachBefore(hC),t}function f(e){var n=i[e.depth],r=e.x0+n,c=e.y0+n,f=e.x1-n,h=e.y1-n;f{const n=t.tile();n.ratio&&t.tile(n.ratio(e))},t.method=e=>{lt(VC,e)?t.tile(VC[e]):s("Unrecognized Treemap layout method: "+e)},t},params:["method","ratio","size","round","padding","paddingInner","paddingOuter","paddingTop","paddingRight","paddingBottom","paddingLeft"],fields:XC});var ZC=Object.freeze({__proto__:null,nest:zC,pack:qC,partition:jC,stratify:IC,tree:YC,treelinks:GC,treemap:JC});const QC=4278190080;function KC(t,e,n){return new Uint32Array(t.getImageData(0,0,e,n).data.buffer)}function tF(t,e,n){if(!e.length)return;const r=e[0].mark.marktype;"group"===r?e.forEach((e=>{e.items.forEach((e=>tF(t,e.items,n)))})):Hy[r].draw(t,{items:n?e.map(eF):e})}function eF(t){const e=ba(t,{});return e.stroke&&0!==e.strokeOpacity||e.fill&&0!==e.fillOpacity?{...e,strokeOpacity:1,stroke:"#000",fillOpacity:0}:e}const nF=5,rF=31,iF=32,oF=new Uint32Array(iF+1),aF=new Uint32Array(iF+1);aF[0]=0,oF[0]=~aF[0];for(let t=1;t<=iF;++t)aF[t]=aF[t-1]<<1|1,oF[t]=~aF[t];function sF(t,e,n){const r=Math.max(1,Math.sqrt(t*e/1e6)),i=~~((t+2*n+r)/r),o=~~((e+2*n+r)/r),a=t=>~~((t+n)/r);return a.invert=t=>t*r-n,a.bitmap=()=>function(t,e){const n=new Uint32Array(~~((t*e+iF)/iF));function r(t,e){n[t]|=e}function i(t,e){n[t]&=e}return{array:n,get:(e,r)=>{const i=r*t+e;return n[i>>>nF]&1<<(i&rF)},set:(e,n)=>{const i=n*t+e;r(i>>>nF,1<<(i&rF))},clear:(e,n)=>{const r=n*t+e;i(r>>>nF,~(1<<(r&rF)))},getRange:(e,r,i,o)=>{let a,s,u,l,c=o;for(;c>=r;--c)if(a=c*t+e,s=c*t+i,u=a>>>nF,l=s>>>nF,u===l){if(n[u]&oF[a&rF]&aF[1+(s&rF)])return!0}else{if(n[u]&oF[a&rF])return!0;if(n[l]&aF[1+(s&rF)])return!0;for(let t=u+1;t{let a,s,u,l,c;for(;n<=o;++n)if(a=n*t+e,s=n*t+i,u=a>>>nF,l=s>>>nF,u===l)r(u,oF[a&rF]&aF[1+(s&rF)]);else for(r(u,oF[a&rF]),r(l,aF[1+(s&rF)]),c=u+1;c{let a,s,u,l,c;for(;n<=o;++n)if(a=n*t+e,s=n*t+r,u=a>>>nF,l=s>>>nF,u===l)i(u,aF[a&rF]|oF[1+(s&rF)]);else for(i(u,aF[a&rF]),i(l,oF[1+(s&rF)]),c=u+1;cn<0||r<0||o>=e||i>=t}}(i,o),a.ratio=r,a.padding=n,a.width=t,a.height=e,a}function uF(t,e,n,r,i,o){let a=n/2;return t-a<0||t+a>i||e-(a=r/2)<0||e+a>o}function lF(t,e,n,r,i,o,a,s){const u=i*o/(2*r),l=t(e-u),c=t(e+u),f=t(n-(o/=2)),h=t(n+o);return a.outOfBounds(l,f,c,h)||a.getRange(l,f,c,h)||s&&s.getRange(l,f,c,h)}const cF=[-1,-1,1,1],fF=[-1,1,-1,1];const hF=["right","center","left"],dF=["bottom","middle","top"];function pF(t,e,n,r,i,o,a,s,u,l,c,f){return!(i.outOfBounds(t,n,e,r)||(f&&o||i).getRange(t,n,e,r))}const gF={"top-left":0,top:1,"top-right":2,left:4,middle:5,right:6,"bottom-left":8,bottom:9,"bottom-right":10},mF={naive:function(t,e,n,r){const i=t.width,o=t.height;return function(t){const e=t.datum.datum.items[r].items,n=e.length,a=t.datum.fontSize,s=My.width(t.datum,t.datum.text);let u,l,c,f,h,d,p,g=0;for(let r=0;r=g&&(g=p,t.x=h,t.y=d);return h=s/2,d=a/2,u=t.x-h,l=t.x+h,c=t.y-d,f=t.y+d,t.align="center",u<0&&l<=i?t.align="left":0<=u&&i=1;)h=(d+p)/2,lF(t,c,f,l,u,h,a,s)?p=h:d=h;if(d>r)return[c,f,d,!0]}}return function(e){const s=e.datum.datum.items[r].items,l=s.length,c=e.datum.fontSize,f=My.width(e.datum,e.datum.text);let h,d,p,g,m,y,v,_,x,b,w,k,A,M,E,D,C,F=n?c:0,S=!1,$=!1,T=0;for(let r=0;rd&&(C=h,h=d,d=C),p>g&&(C=p,p=g,g=C),x=t(h),w=t(d),b=~~((x+w)/2),k=t(p),M=t(g),A=~~((k+M)/2),v=b;v>=x;--v)for(_=A;_>=k;--_)D=u(v,_,F,f,c),D&&([e.x,e.y,F,S]=D);for(v=b;v<=w;++v)for(_=A;_<=M;++_)D=u(v,_,F,f,c),D&&([e.x,e.y,F,S]=D);S||n||(E=Math.abs(d-h+g-p),m=(h+d)/2,y=(p+g)/2,E>=T&&!uF(m,y,f,c,i,o)&&!lF(t,m,y,c,f,c,a,null)&&(T=E,e.x=m,e.y=y,$=!0))}return!(!S&&!$)&&(m=f/2,y=c/2,a.setRange(t(e.x-m),t(e.y-y),t(e.x+m),t(e.y+y)),e.align="center",e.baseline="middle",!0)}},floodfill:function(t,e,n,r){const i=t.width,o=t.height,a=e[0],s=e[1],u=t.bitmap();return function(e){const l=e.datum.datum.items[r].items,c=l.length,f=e.datum.fontSize,h=My.width(e.datum,e.datum.text),d=[];let p,g,m,y,v,_,x,b,w,k,A,M,E=n?f:0,D=!1,C=!1,F=0;for(let r=0;r=1;)A=(w+k)/2,lF(t,v,_,f,h,A,a,s)?k=A:w=A;w>E&&(e.x=v,e.y=_,E=w,D=!0)}}D||n||(M=Math.abs(g-p+y-m),v=(p+g)/2,_=(m+y)/2,M>=F&&!uF(v,_,h,f,i,o)&&!lF(t,v,_,f,h,f,a,null)&&(F=M,e.x=v,e.y=_,C=!0))}return!(!D&&!C)&&(v=h/2,_=f/2,a.setRange(t(e.x-v),t(e.y-_),t(e.x+v),t(e.y+_)),e.align="center",e.baseline="middle",!0)}}};function yF(t,e,n,r,i,o,a,s,u,l,c){if(!t.length)return t;const f=Math.max(r.length,i.length),h=function(t,e){const n=new Float64Array(e),r=t.length;for(let e=0;e[t.x,t.x,t.x,t.y,t.y,t.y];return t?"line"===t||"area"===t?t=>i(t.datum):"line"===e?t=>{const e=t.datum.items[r].items;return i(e.length?e["start"===n?0:e.length-1]:{x:NaN,y:NaN})}:t=>{const e=t.datum.bounds;return[e.x1,(e.x1+e.x2)/2,e.x2,e.y1,(e.y1+e.y2)/2,e.y2]}:i}(p,g,s,u),v=null===l||l===1/0,_=m&&"naive"===c;var x;let b=-1,w=-1;const k=t.map((t=>{const e=v?My.width(t,t.text):void 0;return b=Math.max(b,e),w=Math.max(w,t.fontSize),{datum:t,opacity:0,x:void 0,y:void 0,align:void 0,baseline:void 0,boundary:y(t),textWidth:e}}));l=null===l||l===1/0?Math.max(b,w)+Math.max(...r):l;const A=sF(e[0],e[1],l);let M;if(!_){n&&k.sort(((t,e)=>n(t.datum,e.datum)));let e=!1;for(let t=0;tt.datum));M=o.length||r?function(t,e,n,r,i){const o=t.width,a=t.height,s=r||i,u=$c(o,a).getContext("2d"),l=$c(o,a).getContext("2d"),c=s&&$c(o,a).getContext("2d");n.forEach((t=>tF(u,t,!1))),tF(l,e,!1),s&&tF(c,e,!0);const f=KC(u,o,a),h=KC(l,o,a),d=s&&KC(c,o,a),p=t.bitmap(),g=s&&t.bitmap();let m,y,v,_,x,b,w,k;for(y=0;yn.set(t(e.boundary[0]),t(e.boundary[3])))),[n,void 0]}(A,a&&k)}const E=m?mF[c](A,M,a,u):function(t,e,n,r){const i=t.width,o=t.height,a=e[0],s=e[1],u=r.length;return function(e){const l=e.boundary,c=e.datum.fontSize;if(l[2]<0||l[5]<0||l[0]>i||l[3]>o)return!1;let f,h,d,p,g,m,y,v,_,x,b,w,k,A,M,E=e.textWidth??0;for(let i=0;i>>2&3)-1,d=0===f&&0===h||r[i]<0,p=f&&h?Math.SQRT1_2:1,g=r[i]<0?-1:1,m=l[1+f]+r[i]*f*p,b=l[4+h]+g*c*h/2+r[i]*h*p,v=b-c/2,_=b+c/2,w=t(m),A=t(v),M=t(_),!E){if(!pF(w,w,A,M,a,s,0,0,0,0,0,d))continue;E=My.width(e.datum,e.datum.text)}if(x=m+g*E*f/2,m=x-E/2,y=x+E/2,w=t(m),k=t(y),pF(w,k,A,M,a,s,0,0,0,0,0,d))return e.x=f?f*g<0?y:m:x,e.y=h?h*g<0?_:v:b,e.align=hF[f*g+1],e.baseline=dF[h*g+1],a.setRange(w,A,k,M),!0}return!1}}(A,M,d,h);return k.forEach((t=>t.opacity=+E(t))),k}const vF=["x","y","opacity","align","baseline"],_F=["top-left","left","bottom-left","top","bottom","top-right","right","bottom-right"];function xF(t){Ja.call(this,null,t)}xF.Definition={type:"Label",metadata:{modifies:!0},params:[{name:"size",type:"number",array:!0,length:2,required:!0},{name:"sort",type:"compare"},{name:"anchor",type:"string",array:!0,default:_F},{name:"offset",type:"number",array:!0,default:[1]},{name:"padding",type:"number",default:0,null:!0},{name:"lineAnchor",type:"string",values:["start","end"],default:"end"},{name:"markIndex",type:"number",default:0},{name:"avoidBaseMark",type:"boolean",default:!0},{name:"avoidMarks",type:"data",array:!0},{name:"method",type:"string",default:"naive"},{name:"as",type:"string",array:!0,length:vF.length,default:vF}]},dt(xF,Ja,{transform(t,e){const n=t.modified();if(!(n||e.changed(e.ADD_REM)||function(n){const r=t[n];return J(r)&&e.modified(r.fields)}("sort")))return;t.size&&2===t.size.length||s("Size parameter should be specified as a [width, height] array.");const r=t.as||vF;return yF(e.materialize(e.SOURCE).source||[],t.size,t.sort,V(null==t.offset?1:t.offset),V(t.anchor||_F),t.avoidMarks||[],!1!==t.avoidBaseMark,t.lineAnchor||"end",t.markIndex||0,void 0===t.padding?0:t.padding,t.method||"naive").forEach((t=>{const e=t.datum;e[r[0]]=t.x,e[r[1]]=t.y,e[r[2]]=t.opacity,e[r[3]]=t.align,e[r[4]]=t.baseline})),e.reflow(n).modifies(r)}});var bF=Object.freeze({__proto__:null,label:xF});function wF(t,e){var n,r,i,o,a,s,u=[],l=function(t){return t(o)};if(null==e)u.push(t);else for(n={},r=0,i=t.length;r{Ls(e,t.x,t.y,t.bandwidth||.3).forEach((t=>{const n={};for(let t=0;t"poly"===t?e:"quad"===t?2:1)(a,u),c=t.as||[n(t.x),n(t.y)],f=AF[a],h=[];let d=t.extent;lt(AF,a)||s("Invalid regression method: "+a),null!=d&&"log"===a&&d[0]<=0&&(e.dataflow.warn("Ignoring extent with values <= 0 for log regression."),d=null),i.forEach((n=>{if(n.length<=l)return void e.dataflow.warn("Skipping regression with more parameters than data points.");const r=f(n,t.x,t.y,u);if(t.params)return void h.push(_a({keys:n.dims,coef:r.coef,rSquared:r.rSquared}));const i=d||at(n,t.x),s=t=>{const e={};for(let t=0;ts([t,r.predict(t)]))):Is(r.predict,i,25,200).forEach(s)})),this.value&&(r.rem=this.value),this.value=r.add=r.source=h}return r}});var EF=Object.freeze({__proto__:null,loess:kF,regression:MF});const DF=134217729,CF=33306690738754706e-32;function FF(t,e,n,r,i){let o,a,s,u,l=e[0],c=r[0],f=0,h=0;c>l==c>-l?(o=l,l=e[++f]):(o=c,c=r[++h]);let d=0;if(fl==c>-l?(a=l+o,s=o-(a-l),l=e[++f]):(a=c+o,s=o-(a-c),c=r[++h]),o=a,0!==s&&(i[d++]=s);fl==c>-l?(a=o+l,u=a-o,s=o-(a-u)+(l-u),l=e[++f]):(a=o+c,u=a-o,s=o-(a-u)+(c-u),c=r[++h]),o=a,0!==s&&(i[d++]=s);for(;f0!=s>0)return u;const l=Math.abs(a+s);return Math.abs(u)>=33306690738754716e-32*l?u:-function(t,e,n,r,i,o,a){let s,u,l,c,f,h,d,p,g,m,y,v,_,x,b,w,k,A;const M=t-i,E=n-i,D=e-o,C=r-o;x=M*C,h=DF*M,d=h-(h-M),p=M-d,h=DF*C,g=h-(h-C),m=C-g,b=p*m-(x-d*g-p*g-d*m),w=D*E,h=DF*D,d=h-(h-D),p=D-d,h=DF*E,g=h-(h-E),m=E-g,k=p*m-(w-d*g-p*g-d*m),y=b-k,f=b-y,BF[0]=b-(y+f)+(f-k),v=x+y,f=v-x,_=x-(v-f)+(y-f),y=_-w,f=_-y,BF[1]=_-(y+f)+(f-w),A=v+y,f=A-v,BF[2]=v-(A-f)+(y-f),BF[3]=A;let F=function(t,e){let n=e[0];for(let r=1;r=S||-F>=S)return F;if(f=t-M,s=t-(M+f)+(f-i),f=n-E,l=n-(E+f)+(f-i),f=e-D,u=e-(D+f)+(f-o),f=r-C,c=r-(C+f)+(f-o),0===s&&0===u&&0===l&&0===c)return F;if(S=TF*a+CF*Math.abs(F),F+=M*c+C*s-(D*l+E*u),F>=S||-F>=S)return F;x=s*C,h=DF*s,d=h-(h-s),p=s-d,h=DF*C,g=h-(h-C),m=C-g,b=p*m-(x-d*g-p*g-d*m),w=u*E,h=DF*u,d=h-(h-u),p=u-d,h=DF*E,g=h-(h-E),m=E-g,k=p*m-(w-d*g-p*g-d*m),y=b-k,f=b-y,RF[0]=b-(y+f)+(f-k),v=x+y,f=v-x,_=x-(v-f)+(y-f),y=_-w,f=_-y,RF[1]=_-(y+f)+(f-w),A=v+y,f=A-v,RF[2]=v-(A-f)+(y-f),RF[3]=A;const $=FF(4,BF,4,RF,zF);x=M*c,h=DF*M,d=h-(h-M),p=M-d,h=DF*c,g=h-(h-c),m=c-g,b=p*m-(x-d*g-p*g-d*m),w=D*l,h=DF*D,d=h-(h-D),p=D-d,h=DF*l,g=h-(h-l),m=l-g,k=p*m-(w-d*g-p*g-d*m),y=b-k,f=b-y,RF[0]=b-(y+f)+(f-k),v=x+y,f=v-x,_=x-(v-f)+(y-f),y=_-w,f=_-y,RF[1]=_-(y+f)+(f-w),A=v+y,f=A-v,RF[2]=v-(A-f)+(y-f),RF[3]=A;const T=FF($,zF,4,RF,NF);x=s*c,h=DF*s,d=h-(h-s),p=s-d,h=DF*c,g=h-(h-c),m=c-g,b=p*m-(x-d*g-p*g-d*m),w=u*l,h=DF*u,d=h-(h-u),p=u-d,h=DF*l,g=h-(h-l),m=l-g,k=p*m-(w-d*g-p*g-d*m),y=b-k,f=b-y,RF[0]=b-(y+f)+(f-k),v=x+y,f=v-x,_=x-(v-f)+(y-f),y=_-w,f=_-y,RF[1]=_-(y+f)+(f-w),A=v+y,f=A-v,RF[2]=v-(A-f)+(y-f),RF[3]=A;const B=FF(T,NF,4,RF,OF);return OF[B-1]}(t,e,n,r,i,o,l)}const LF=Math.pow(2,-52),qF=new Uint32Array(512);class PF{static from(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:GF,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:VF;const r=t.length,i=new Float64Array(2*r);for(let o=0;o>1;if(e>0&&"number"!=typeof t[0])throw new Error("Expected coords to contain numbers.");this.coords=t;const n=Math.max(2*e-5,0);this._triangles=new Uint32Array(3*n),this._halfedges=new Int32Array(3*n),this._hashSize=Math.ceil(Math.sqrt(e)),this._hullPrev=new Uint32Array(e),this._hullNext=new Uint32Array(e),this._hullTri=new Uint32Array(e),this._hullHash=new Int32Array(this._hashSize).fill(-1),this._ids=new Uint32Array(e),this._dists=new Float64Array(e),this.update()}update(){const{coords:t,_hullPrev:e,_hullNext:n,_hullTri:r,_hullHash:i}=this,o=t.length>>1;let a=1/0,s=1/0,u=-1/0,l=-1/0;for(let e=0;eu&&(u=n),r>l&&(l=r),this._ids[e]=e}const c=(a+u)/2,f=(s+l)/2;let h,d,p,g=1/0;for(let e=0;e0&&(d=e,g=n)}let v=t[2*d],_=t[2*d+1],x=1/0;for(let e=0;er&&(e[n++]=i,r=this._dists[i])}return this.hull=e.subarray(0,n),this.triangles=new Uint32Array(0),void(this.halfedges=new Uint32Array(0))}if(UF(m,y,v,_,b,w)<0){const t=d,e=v,n=_;d=p,v=b,_=w,p=t,b=e,w=n}const k=function(t,e,n,r,i,o){const a=n-t,s=r-e,u=i-t,l=o-e,c=a*a+s*s,f=u*u+l*l,h=.5/(a*l-s*u),d=t+(l*c-s*f)*h,p=e+(a*f-u*c)*h;return{x:d,y:p}}(m,y,v,_,b,w);this._cx=k.x,this._cy=k.y;for(let e=0;e0&&Math.abs(l-o)<=LF&&Math.abs(c-a)<=LF)continue;if(o=l,a=c,u===h||u===d||u===p)continue;let f=0;for(let t=0,e=this._hashKey(l,c);t=0;)if(m=g,m===f){m=-1;break}if(-1===m)continue;let y=this._addTriangle(m,u,n[m],-1,-1,r[m]);r[u]=this._legalize(y+2),r[m]=y,A++;let v=n[m];for(;g=n[v],UF(l,c,t[2*v],t[2*v+1],t[2*g],t[2*g+1])<0;)y=this._addTriangle(v,u,g,r[u],-1,r[v]),r[u]=this._legalize(y+2),n[v]=v,A--,v=g;if(m===f)for(;g=e[m],UF(l,c,t[2*g],t[2*g+1],t[2*m],t[2*m+1])<0;)y=this._addTriangle(g,u,m,-1,r[m],r[g]),this._legalize(y+2),r[g]=y,n[m]=m,A--,m=g;this._hullStart=e[u]=m,n[m]=e[v]=u,n[u]=v,i[this._hashKey(l,c)]=u,i[this._hashKey(t[2*m],t[2*m+1])]=m}this.hull=new Uint32Array(A);for(let t=0,e=this._hullStart;t0?3-n:1+n)/4}(t-this._cx,e-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:e,_halfedges:n,coords:r}=this;let i=0,o=0;for(;;){const a=n[t],s=t-t%3;if(o=s+(t+2)%3,-1===a){if(0===i)break;t=qF[--i];continue}const u=a-a%3,l=s+(t+1)%3,c=u+(a+2)%3,f=e[o],h=e[t],d=e[l],p=e[c];if(IF(r[2*f],r[2*f+1],r[2*h],r[2*h+1],r[2*d],r[2*d+1],r[2*p],r[2*p+1])){e[t]=p,e[a]=f;const r=n[c];if(-1===r){let e=this._hullStart;do{if(this._hullTri[e]===c){this._hullTri[e]=t;break}e=this._hullPrev[e]}while(e!==this._hullStart)}this._link(t,r),this._link(a,n[o]),this._link(o,c);const s=u+(a+1)%3;i=n&&e[t[a]]>o;)t[a+1]=t[a--];t[a+1]=r}else{let i=n+1,o=r;YF(t,n+r>>1,i),e[t[n]]>e[t[r]]&&YF(t,n,r),e[t[i]]>e[t[r]]&&YF(t,i,r),e[t[n]]>e[t[i]]&&YF(t,n,i);const a=t[i],s=e[a];for(;;){do{i++}while(e[t[i]]s);if(o=o-n?(HF(t,e,i,r),HF(t,e,n,o-1)):(HF(t,e,n,o-1),HF(t,e,i,r))}}function YF(t,e,n){const r=t[e];t[e]=t[n],t[n]=r}function GF(t){return t[0]}function VF(t){return t[1]}const XF=1e-6;class JF{constructor(){this._x0=this._y0=this._x1=this._y1=null,this._=""}moveTo(t,e){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+e}`}closePath(){null!==this._x1&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")}lineTo(t,e){this._+=`L${this._x1=+t},${this._y1=+e}`}arc(t,e,n){const r=(t=+t)+(n=+n),i=e=+e;if(n<0)throw new Error("negative radius");null===this._x1?this._+=`M${r},${i}`:(Math.abs(this._x1-r)>XF||Math.abs(this._y1-i)>XF)&&(this._+="L"+r+","+i),n&&(this._+=`A${n},${n},0,1,1,${t-n},${e}A${n},${n},0,1,1,${this._x1=r},${this._y1=i}`)}rect(t,e,n,r){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+e}h${+n}v${+r}h${-n}Z`}value(){return this._||null}}class ZF{constructor(){this._=[]}moveTo(t,e){this._.push([t,e])}closePath(){this._.push(this._[0].slice())}lineTo(t,e){this._.push([t,e])}value(){return this._.length?this._:null}}let QF=class{constructor(t){let[e,n,r,i]=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[0,0,960,500];if(!((r=+r)>=(e=+e)&&(i=+i)>=(n=+n)))throw new Error("invalid bounds");this.delaunay=t,this._circumcenters=new Float64Array(2*t.points.length),this.vectors=new Float64Array(2*t.points.length),this.xmax=r,this.xmin=e,this.ymax=i,this.ymin=n,this._init()}update(){return this.delaunay.update(),this._init(),this}_init(){const{delaunay:{points:t,hull:e,triangles:n},vectors:r}=this;let i,o;const a=this.circumcenters=this._circumcenters.subarray(0,n.length/3*2);for(let r,s,u=0,l=0,c=n.length;u1;)i-=2;for(let t=2;t0){if(e>=this.ymax)return null;(i=(this.ymax-e)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/n)this.xmax?2:0)|(ethis.ymax?8:0)}_simplify(t){if(t&&t.length>4){for(let e=0;e1&&void 0!==arguments[1]?arguments[1]:eS,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:nS,r=arguments.length>3?arguments[3]:void 0;return new iS("length"in t?function(t,e,n,r){const i=t.length,o=new Float64Array(2*i);for(let a=0;a2&&function(t){const{triangles:e,coords:n}=t;for(let t=0;t1e-10)return!1}return!0}(t)){this.collinear=Int32Array.from({length:e.length/2},((t,e)=>e)).sort(((t,n)=>e[2*t]-e[2*n]||e[2*t+1]-e[2*n+1]));const t=this.collinear[0],n=this.collinear[this.collinear.length-1],r=[e[2*t],e[2*t+1],e[2*n],e[2*n+1]],i=1e-8*Math.hypot(r[3]-r[1],r[2]-r[0]);for(let t=0,n=e.length/2;t0&&(this.triangles=new Int32Array(3).fill(-1),this.halfedges=new Int32Array(3).fill(-1),this.triangles[0]=r[0],o[r[0]]=1,2===r.length&&(o[r[1]]=0,this.triangles[1]=r[1],this.triangles[2]=r[1]))}voronoi(t){return new QF(this,t)}*neighbors(t){const{inedges:e,hull:n,_hullIndex:r,halfedges:i,triangles:o,collinear:a}=this;if(a){const e=a.indexOf(t);return e>0&&(yield a[e-1]),void(e2&&void 0!==arguments[2]?arguments[2]:0;if((t=+t)!=t||(e=+e)!=e)return-1;const r=n;let i;for(;(i=this._step(n,t,e))>=0&&i!==n&&i!==r;)n=i;return i}_step(t,e,n){const{inedges:r,hull:i,_hullIndex:o,halfedges:a,triangles:s,points:u}=this;if(-1===r[t]||!u.length)return(t+1)%(u.length>>1);let l=t,c=tS(e-u[2*t],2)+tS(n-u[2*t+1],2);const f=r[t];let h=f;do{let r=s[h];const f=tS(e-u[2*r],2)+tS(n-u[2*r+1],2);if(f=f));)if(e.x=a+i,e.y=l+o,!(e.x+e.x0<0||e.y+e.y0<0||e.x+e.x1>s[0]||e.y+e.y1>s[1])&&(!n||!pS(e,t,s[0]))&&(!n||mS(e,n))){for(var g,m=e.sprite,y=e.width>>5,v=s[0]>>5,_=e.x-(y<<4),x=127&_,b=32-x,w=e.y1-e.y0,k=(e.y+e.y0)*v+(_>>5),A=0;A>>x:0);k+=v}return e.sprite=null,!0}return!1}return f.layout=function(){for(var u=function(t){t.width=t.height=1;var e=Math.sqrt(t.getContext("2d").getImageData(0,0,1,1).data.length>>2);t.width=(cS<<5)/e,t.height=fS/e;var n=t.getContext("2d");return n.fillStyle=n.strokeStyle="red",n.textAlign="center",{context:n,ratio:e}}($c()),f=function(t){var e=[],n=-1;for(;++n>5)*s[1]),d=null,p=l.length,g=-1,m=[],y=l.map((s=>({text:t(s),font:e(s),style:r(s),weight:i(s),rotate:o(s),size:~~(n(s)+1e-14),padding:a(s),xoff:0,yoff:0,x1:0,y1:0,x0:0,y0:0,hasText:!1,sprite:null,datum:s}))).sort(((t,e)=>e.size-t.size));++g>1,v.y=s[1]*(c()+.5)>>1,dS(u,v,y,g),v.hasText&&h(f,v,d)&&(m.push(v),d?gS(d,v):d=[{x:v.x+v.x0,y:v.y+v.y0},{x:v.x+v.x1,y:v.y+v.y1}],v.x-=s[0]>>1,v.y-=s[1]>>1)}return m},f.words=function(t){return arguments.length?(l=t,f):l},f.size=function(t){return arguments.length?(s=[+t[0],+t[1]],f):s},f.font=function(t){return arguments.length?(e=vS(t),f):e},f.fontStyle=function(t){return arguments.length?(r=vS(t),f):r},f.fontWeight=function(t){return arguments.length?(i=vS(t),f):i},f.rotate=function(t){return arguments.length?(o=vS(t),f):o},f.text=function(e){return arguments.length?(t=vS(e),f):t},f.spiral=function(t){return arguments.length?(u=_S[t]||t,f):u},f.fontSize=function(t){return arguments.length?(n=vS(t),f):n},f.padding=function(t){return arguments.length?(a=vS(t),f):a},f.random=function(t){return arguments.length?(c=t,f):c},f}function dS(t,e,n,r){if(!e.sprite){var i=t.context,o=t.ratio;i.clearRect(0,0,(cS<<5)/o,fS/o);var a,s,u,l,c,f=0,h=0,d=0,p=n.length;for(--r;++r>5<<5,u=~~Math.max(Math.abs(v+_),Math.abs(v-_))}else a=a+31>>5<<5;if(u>d&&(d=u),f+a>=cS<<5&&(f=0,h+=d,d=0),h+u>=fS)break;i.translate((f+(a>>1))/o,(h+(u>>1))/o),e.rotate&&i.rotate(e.rotate*lS),i.fillText(e.text,0,0),e.padding&&(i.lineWidth=2*e.padding,i.strokeText(e.text,0,0)),i.restore(),e.width=a,e.height=u,e.xoff=f,e.yoff=h,e.x1=a>>1,e.y1=u>>1,e.x0=-e.x1,e.y0=-e.y1,e.hasText=!0,f+=a}for(var b=i.getImageData(0,0,(cS<<5)/o,fS/o).data,w=[];--r>=0;)if((e=n[r]).hasText){for(s=(a=e.width)>>5,u=e.y1-e.y0,l=0;l>5),E=b[(h+c)*(cS<<5)+(f+l)<<2]?1<<31-l%32:0;w[M]|=E,k|=E}k?A=c:(e.y0++,u--,c--,h++)}e.y1=e.y0+A,e.sprite=w.slice(0,(e.y1-e.y0)*s)}}}function pS(t,e,n){n>>=5;for(var r,i=t.sprite,o=t.width>>5,a=t.x-(o<<4),s=127&a,u=32-s,l=t.y1-t.y0,c=(t.y+t.y0)*n+(a>>5),f=0;f>>s:0))&e[c+h])return!0;c+=n}return!1}function gS(t,e){var n=t[0],r=t[1];e.x+e.x0r.x&&(r.x=e.x+e.x1),e.y+e.y1>r.y&&(r.y=e.y+e.y1)}function mS(t,e){return t.x+t.x1>e[0].x&&t.x+t.x0e[0].y&&t.y+t.y0e(t(n))}i.forEach((t=>{t[a[0]]=NaN,t[a[1]]=NaN,t[a[3]]=0}));const c=o.words(i).text(e.text).size(e.size||[500,500]).padding(e.padding||1).spiral(e.spiral||"archimedean").rotate(e.rotate||0).font(e.font||"sans-serif").fontStyle(e.fontStyle||"normal").fontWeight(e.fontWeight||"normal").fontSize(l).random(t.random).layout(),f=o.size(),h=f[0]>>1,d=f[1]>>1,p=c.length;for(let t,e,n=0;nnew Uint8Array(t),MS=t=>new Uint16Array(t),ES=t=>new Uint32Array(t);function DS(t,e,n){const r=(e<257?AS:e<65537?MS:ES)(t);return n&&r.set(n),r}function CS(t,e,n){const r=1<{const r=t[e],i=t[n];return ri?1:0})),function(t,e){return Array.from(e,(e=>t[e]))}(t,e)}(h,u),a)l=e,c=t,e=Array(a+s),t=ES(a+s),function(t,e,n,r,i,o,a,s,u){let l,c=0,f=0;for(l=0;c0)for(f=0;ft,size:()=>n}}function SS(t){Ja.call(this,function(){let t=8,e=[],n=ES(0),r=DS(0,t),i=DS(0,t);return{data:()=>e,seen:()=>n=function(t,e,n){return t.length>=e?t:((n=n||new t.constructor(e)).set(t),n)}(n,e.length),add(t){for(let n,r=0,i=e.length,o=t.length;re.length,curr:()=>r,prev:()=>i,reset:t=>i[t]=r[t],all:()=>t<257?255:t<65537?65535:4294967295,set(t,e){r[t]|=e},clear(t,e){r[t]&=~e},resize(e,n){(e>r.length||n>t)&&(t=Math.max(n,t),r=DS(e,t,r),i=DS(e,t))}}}(),t),this._indices=null,this._dims=null}function $S(t){Ja.call(this,null,t)}SS.Definition={type:"CrossFilter",metadata:{},params:[{name:"fields",type:"field",array:!0,required:!0},{name:"query",type:"array",array:!0,required:!0,content:{type:"number",array:!0,length:2}}]},dt(SS,Ja,{transform(t,e){return this._dims?t.modified("fields")||t.fields.some((t=>e.modified(t.fields)))?this.reinit(t,e):this.eval(t,e):this.init(t,e)},init(t,e){const n=t.fields,r=t.query,i=this._indices={},o=this._dims=[],a=r.length;let s,u,l=0;for(;l{const t=i.remove(e,n);for(const e in r)r[e].reindex(t)}))},update(t,e,n){const r=this._dims,i=t.query,o=e.stamp,a=r.length;let s,u,l=0;for(n.filters=0,u=0;ud)for(m=d,y=Math.min(f,p);mp)for(m=Math.max(f,p),y=h;mc)for(d=c,p=Math.min(u,f);df)for(d=Math.max(u,f),p=l;ds[t]&n?null:a[t];return o.filter(o.MOD,l),i&i-1?(o.filter(o.ADD,(t=>{const e=s[t]&n;return!e&&e^u[t]&n?a[t]:null})),o.filter(o.REM,(t=>{const e=s[t]&n;return e&&!(e^e^u[t]&n)?a[t]:null}))):(o.filter(o.ADD,l),o.filter(o.REM,(t=>(s[t]&n)===i?a[t]:null))),o.filter(o.SOURCE,(t=>l(t._index)))}});var TS=Object.freeze({__proto__:null,crossfilter:SS,resolvefilter:$S});const BS="Literal",zS="Property",NS="ArrayExpression",OS="BinaryExpression",RS="CallExpression",US="ConditionalExpression",LS="LogicalExpression",qS="MemberExpression",PS="ObjectExpression",jS="UnaryExpression";function IS(t){this.type=t}var WS,HS,YS,GS,VS;IS.prototype.visit=function(t){let e,n,r;if(t(this))return 1;for(e=function(t){switch(t.type){case NS:return t.elements;case OS:case LS:return[t.left,t.right];case RS:return[t.callee].concat(t.arguments);case US:return[t.test,t.consequent,t.alternate];case qS:return[t.object,t.property];case PS:return t.properties;case zS:return[t.key,t.value];case jS:return[t.argument];default:return[]}}(this),n=0,r=e.length;n",WS[ZS]="Identifier",WS[QS]="Keyword",WS[KS]="Null",WS[t$]="Numeric",WS[e$]="Punctuator",WS[n$]="String",WS[9]="RegularExpression";var r$="ArrayExpression",i$="BinaryExpression",o$="CallExpression",a$="ConditionalExpression",s$="Identifier",u$="Literal",l$="LogicalExpression",c$="MemberExpression",f$="ObjectExpression",h$="Property",d$="UnaryExpression",p$="Unexpected token %0",g$="Unexpected number",m$="Unexpected string",y$="Unexpected identifier",v$="Unexpected reserved word",_$="Unexpected end of input",x$="Invalid regular expression",b$="Invalid regular expression: missing /",w$="Octal literals are not allowed in strict mode.",k$="Duplicate data property in object literal not allowed in strict mode",A$="ILLEGAL",M$="Disabled.",E$=new RegExp("[\\xAA\\xB5\\xBA\\xC0-\\xD6\\xD8-\\xF6\\xF8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u037F\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u052F\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0-\\u08B2\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0980\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F8\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191E\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2160-\\u2188\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3007\\u3021-\\u3029\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA69D\\uA6A0-\\uA6EF\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA7AD\\uA7B0\\uA7B1\\uA7F7-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uA9E0-\\uA9E4\\uA9E6-\\uA9EF\\uA9FA-\\uA9FE\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA7E-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uAB30-\\uAB5A\\uAB5C-\\uAB5F\\uAB64\\uAB65\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC]"),D$=new RegExp("[\\xAA\\xB5\\xBA\\xC0-\\xD6\\xD8-\\xF6\\xF8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0300-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u037F\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u0483-\\u0487\\u048A-\\u052F\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u0591-\\u05BD\\u05BF\\u05C1\\u05C2\\u05C4\\u05C5\\u05C7\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0610-\\u061A\\u0620-\\u0669\\u066E-\\u06D3\\u06D5-\\u06DC\\u06DF-\\u06E8\\u06EA-\\u06FC\\u06FF\\u0710-\\u074A\\u074D-\\u07B1\\u07C0-\\u07F5\\u07FA\\u0800-\\u082D\\u0840-\\u085B\\u08A0-\\u08B2\\u08E4-\\u0963\\u0966-\\u096F\\u0971-\\u0983\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BC-\\u09C4\\u09C7\\u09C8\\u09CB-\\u09CE\\u09D7\\u09DC\\u09DD\\u09DF-\\u09E3\\u09E6-\\u09F1\\u0A01-\\u0A03\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A3C\\u0A3E-\\u0A42\\u0A47\\u0A48\\u0A4B-\\u0A4D\\u0A51\\u0A59-\\u0A5C\\u0A5E\\u0A66-\\u0A75\\u0A81-\\u0A83\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABC-\\u0AC5\\u0AC7-\\u0AC9\\u0ACB-\\u0ACD\\u0AD0\\u0AE0-\\u0AE3\\u0AE6-\\u0AEF\\u0B01-\\u0B03\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3C-\\u0B44\\u0B47\\u0B48\\u0B4B-\\u0B4D\\u0B56\\u0B57\\u0B5C\\u0B5D\\u0B5F-\\u0B63\\u0B66-\\u0B6F\\u0B71\\u0B82\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BBE-\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCD\\u0BD0\\u0BD7\\u0BE6-\\u0BEF\\u0C00-\\u0C03\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C39\\u0C3D-\\u0C44\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55\\u0C56\\u0C58\\u0C59\\u0C60-\\u0C63\\u0C66-\\u0C6F\\u0C81-\\u0C83\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBC-\\u0CC4\\u0CC6-\\u0CC8\\u0CCA-\\u0CCD\\u0CD5\\u0CD6\\u0CDE\\u0CE0-\\u0CE3\\u0CE6-\\u0CEF\\u0CF1\\u0CF2\\u0D01-\\u0D03\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D-\\u0D44\\u0D46-\\u0D48\\u0D4A-\\u0D4E\\u0D57\\u0D60-\\u0D63\\u0D66-\\u0D6F\\u0D7A-\\u0D7F\\u0D82\\u0D83\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0DCA\\u0DCF-\\u0DD4\\u0DD6\\u0DD8-\\u0DDF\\u0DE6-\\u0DEF\\u0DF2\\u0DF3\\u0E01-\\u0E3A\\u0E40-\\u0E4E\\u0E50-\\u0E59\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB9\\u0EBB-\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EC8-\\u0ECD\\u0ED0-\\u0ED9\\u0EDC-\\u0EDF\\u0F00\\u0F18\\u0F19\\u0F20-\\u0F29\\u0F35\\u0F37\\u0F39\\u0F3E-\\u0F47\\u0F49-\\u0F6C\\u0F71-\\u0F84\\u0F86-\\u0F97\\u0F99-\\u0FBC\\u0FC6\\u1000-\\u1049\\u1050-\\u109D\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u135D-\\u135F\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F8\\u1700-\\u170C\\u170E-\\u1714\\u1720-\\u1734\\u1740-\\u1753\\u1760-\\u176C\\u176E-\\u1770\\u1772\\u1773\\u1780-\\u17D3\\u17D7\\u17DC\\u17DD\\u17E0-\\u17E9\\u180B-\\u180D\\u1810-\\u1819\\u1820-\\u1877\\u1880-\\u18AA\\u18B0-\\u18F5\\u1900-\\u191E\\u1920-\\u192B\\u1930-\\u193B\\u1946-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19B0-\\u19C9\\u19D0-\\u19D9\\u1A00-\\u1A1B\\u1A20-\\u1A5E\\u1A60-\\u1A7C\\u1A7F-\\u1A89\\u1A90-\\u1A99\\u1AA7\\u1AB0-\\u1ABD\\u1B00-\\u1B4B\\u1B50-\\u1B59\\u1B6B-\\u1B73\\u1B80-\\u1BF3\\u1C00-\\u1C37\\u1C40-\\u1C49\\u1C4D-\\u1C7D\\u1CD0-\\u1CD2\\u1CD4-\\u1CF6\\u1CF8\\u1CF9\\u1D00-\\u1DF5\\u1DFC-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u200C\\u200D\\u203F\\u2040\\u2054\\u2071\\u207F\\u2090-\\u209C\\u20D0-\\u20DC\\u20E1\\u20E5-\\u20F0\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2160-\\u2188\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D7F-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2DE0-\\u2DFF\\u2E2F\\u3005-\\u3007\\u3021-\\u302F\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u3099\\u309A\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA62B\\uA640-\\uA66F\\uA674-\\uA67D\\uA67F-\\uA69D\\uA69F-\\uA6F1\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA7AD\\uA7B0\\uA7B1\\uA7F7-\\uA827\\uA840-\\uA873\\uA880-\\uA8C4\\uA8D0-\\uA8D9\\uA8E0-\\uA8F7\\uA8FB\\uA900-\\uA92D\\uA930-\\uA953\\uA960-\\uA97C\\uA980-\\uA9C0\\uA9CF-\\uA9D9\\uA9E0-\\uA9FE\\uAA00-\\uAA36\\uAA40-\\uAA4D\\uAA50-\\uAA59\\uAA60-\\uAA76\\uAA7A-\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEF\\uAAF2-\\uAAF6\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uAB30-\\uAB5A\\uAB5C-\\uAB5F\\uAB64\\uAB65\\uABC0-\\uABEA\\uABEC\\uABED\\uABF0-\\uABF9\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE00-\\uFE0F\\uFE20-\\uFE2D\\uFE33\\uFE34\\uFE4D-\\uFE4F\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF10-\\uFF19\\uFF21-\\uFF3A\\uFF3F\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC]");function C$(t,e){if(!t)throw new Error("ASSERT: "+e)}function F$(t){return t>=48&&t<=57}function S$(t){return"0123456789abcdefABCDEF".includes(t)}function $$(t){return"01234567".includes(t)}function T$(t){return 32===t||9===t||11===t||12===t||160===t||t>=5760&&[5760,6158,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8239,8287,12288,65279].includes(t)}function B$(t){return 10===t||13===t||8232===t||8233===t}function z$(t){return 36===t||95===t||t>=65&&t<=90||t>=97&&t<=122||92===t||t>=128&&E$.test(String.fromCharCode(t))}function N$(t){return 36===t||95===t||t>=65&&t<=90||t>=97&&t<=122||t>=48&&t<=57||92===t||t>=128&&D$.test(String.fromCharCode(t))}const O$={if:1,in:1,do:1,var:1,for:1,new:1,try:1,let:1,this:1,else:1,case:1,void:1,with:1,enum:1,while:1,break:1,catch:1,throw:1,const:1,yield:1,class:1,super:1,return:1,typeof:1,delete:1,switch:1,export:1,import:1,public:1,static:1,default:1,finally:1,extends:1,package:1,private:1,function:1,continue:1,debugger:1,interface:1,protected:1,instanceof:1,implements:1};function R$(){for(;YS1114111||"}"!==t)&&tT({},p$,A$),e<=65535?String.fromCharCode(e):(n=55296+(e-65536>>10),r=56320+(e-65536&1023),String.fromCharCode(n,r))}function q$(){var t,e;for(t=HS.charCodeAt(YS++),e=String.fromCharCode(t),92===t&&(117!==HS.charCodeAt(YS)&&tT({},p$,A$),++YS,(t=U$("u"))&&"\\"!==t&&z$(t.charCodeAt(0))||tT({},p$,A$),e=t);YS>>="===(r=HS.substr(YS,4))?{type:e$,value:r,start:i,end:YS+=4}:">>>"===(n=r.substr(0,3))||"<<="===n||">>="===n?{type:e$,value:n,start:i,end:YS+=3}:a===(e=n.substr(0,2))[1]&&"+-<>&|".includes(a)||"=>"===e?{type:e$,value:e,start:i,end:YS+=2}:("//"===e&&tT({},p$,A$),"<>=!+-*%&|^/".includes(a)?(++YS,{type:e$,value:a,start:i,end:YS}):void tT({},p$,A$))}function I$(){var t,e,n;if(C$(F$((n=HS[YS]).charCodeAt(0))||"."===n,"Numeric literal must start with a decimal digit or a decimal point"),e=YS,t="","."!==n){if(t=HS[YS++],n=HS[YS],"0"===t){if("x"===n||"X"===n)return++YS,function(t){let e="";for(;YS=0&&tT({},x$,n),{value:n,literal:e}}(),r=function(t,e){let n=t;e.includes("u")&&(n=n.replace(/\\u\{([0-9a-fA-F]+)\}/g,((t,e)=>{if(parseInt(e,16)<=1114111)return"x";tT({},x$)})).replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g,"x"));try{new RegExp(n)}catch(t){tT({},x$)}try{return new RegExp(t,e)}catch(t){return null}}(e.value,n.value),{literal:e.literal+n.literal,value:r,regex:{pattern:e.value,flags:n.value},start:t,end:YS}}function H$(){if(R$(),YS>=GS)return{type:JS,start:YS,end:YS};const t=HS.charCodeAt(YS);return z$(t)?P$():40===t||41===t||59===t?j$():39===t||34===t?function(){var t,e,n,r,i="",o=!1;for(C$("'"===(t=HS[YS])||'"'===t,"String literal must starts with a quote"),e=YS,++YS;YS(C$(e":case"<=":case">=":case"instanceof":case"in":e=7;break;case"<<":case">>":case">>>":e=8;break;case"+":case"-":e=9;break;case"*":case"/":case"%":e=11}return e}function yT(){var t,e;return t=function(){var t,e,n,r,i,o,a,s,u,l;if(t=VS,u=gT(),0===(i=mT(r=VS)))return u;for(r.prec=i,Y$(),e=[t,VS],o=[u,r,a=gT()];(i=mT(VS))>0;){for(;o.length>2&&i<=o[o.length-2].prec;)a=o.pop(),s=o.pop().value,u=o.pop(),e.pop(),n=V$(s,u,a),o.push(n);(r=Y$()).prec=i,o.push(r),e.push(VS),n=gT(),o.push(n)}for(n=o[l=o.length-1],e.pop();l>1;)e.pop(),n=V$(o[l-1].value,o[l-2],n),l-=2;return n}(),rT("?")&&(Y$(),e=yT(),nT(":"),t=function(t,e,n){const r=new IS(a$);return r.test=t,r.consequent=e,r.alternate=n,r}(t,e,yT())),t}function vT(){const t=yT();if(rT(","))throw new Error(M$);return t}function _T(t){YS=0,GS=(HS=t).length,VS=null,G$();const e=vT();if(VS.type!==JS)throw new Error("Unexpect token after expression.");return e}var xT={NaN:"NaN",E:"Math.E",LN2:"Math.LN2",LN10:"Math.LN10",LOG2E:"Math.LOG2E",LOG10E:"Math.LOG10E",PI:"Math.PI",SQRT1_2:"Math.SQRT1_2",SQRT2:"Math.SQRT2",MIN_VALUE:"Number.MIN_VALUE",MAX_VALUE:"Number.MAX_VALUE"};function bT(t){function e(e,n,r){return i=>function(e,n,r,i){let o=t(n[0]);return r&&(o=r+"("+o+")",0===r.lastIndexOf("new ",0)&&(o="("+o+")")),o+"."+e+(i<0?"":0===i?"()":"("+n.slice(1).map(t).join(",")+")")}(e,i,n,r)}const n="new Date",r="String",i="RegExp";return{isNaN:"Number.isNaN",isFinite:"Number.isFinite",abs:"Math.abs",acos:"Math.acos",asin:"Math.asin",atan:"Math.atan",atan2:"Math.atan2",ceil:"Math.ceil",cos:"Math.cos",exp:"Math.exp",floor:"Math.floor",hypot:"Math.hypot",log:"Math.log",max:"Math.max",min:"Math.min",pow:"Math.pow",random:"Math.random",round:"Math.round",sin:"Math.sin",sqrt:"Math.sqrt",tan:"Math.tan",clamp:function(e){e.length<3&&s("Missing arguments to clamp function."),e.length>3&&s("Too many arguments to clamp function.");const n=e.map(t);return"Math.max("+n[1]+", Math.min("+n[2]+","+n[0]+"))"},now:"Date.now",utc:"Date.UTC",datetime:n,date:e("getDate",n,0),day:e("getDay",n,0),year:e("getFullYear",n,0),month:e("getMonth",n,0),hours:e("getHours",n,0),minutes:e("getMinutes",n,0),seconds:e("getSeconds",n,0),milliseconds:e("getMilliseconds",n,0),time:e("getTime",n,0),timezoneoffset:e("getTimezoneOffset",n,0),utcdate:e("getUTCDate",n,0),utcday:e("getUTCDay",n,0),utcyear:e("getUTCFullYear",n,0),utcmonth:e("getUTCMonth",n,0),utchours:e("getUTCHours",n,0),utcminutes:e("getUTCMinutes",n,0),utcseconds:e("getUTCSeconds",n,0),utcmilliseconds:e("getUTCMilliseconds",n,0),length:e("length",null,-1),parseFloat:"parseFloat",parseInt:"parseInt",upper:e("toUpperCase",r,0),lower:e("toLowerCase",r,0),substring:e("substring",r),split:e("split",r),trim:e("trim",r,0),regexp:i,test:e("test",i),if:function(e){e.length<3&&s("Missing arguments to if function."),e.length>3&&s("Too many arguments to if function.");const n=e.map(t);return"("+n[0]+"?"+n[1]+":"+n[2]+")"}}}function wT(t){const e=(t=t||{}).allowed?Bt(t.allowed):{},n=t.forbidden?Bt(t.forbidden):{},r=t.constants||xT,i=(t.functions||bT)(h),o=t.globalvar,a=t.fieldvar,u=J(o)?o:t=>`${o}["${t}"]`;let l={},c={},f=0;function h(t){if(xt(t))return t;const e=d[t.type];return null==e&&s("Unsupported type: "+t.type),e(t)}const d={Literal:t=>t.raw,Identifier:t=>{const i=t.name;return f>0?i:lt(n,i)?s("Illegal identifier: "+i):lt(r,i)?r[i]:lt(e,i)?i:(l[i]=1,u(i))},MemberExpression:t=>{const e=!t.computed,n=h(t.object);e&&(f+=1);const r=h(t.property);return n===a&&(c[function(t){const e=t&&t.length-1;return e&&('"'===t[0]&&'"'===t[e]||"'"===t[0]&&"'"===t[e])?t.slice(1,-1):t}(r)]=1),e&&(f-=1),n+(e?"."+r:"["+r+"]")},CallExpression:t=>{"Identifier"!==t.callee.type&&s("Illegal callee type: "+t.callee.type);const e=t.callee.name,n=t.arguments,r=lt(i,e)&&i[e];return r||s("Unrecognized function: "+e),J(r)?r(n):r+"("+n.map(h).join(",")+")"},ArrayExpression:t=>"["+t.elements.map(h).join(",")+"]",BinaryExpression:t=>"("+h(t.left)+" "+t.operator+" "+h(t.right)+")",UnaryExpression:t=>"("+t.operator+h(t.argument)+")",ConditionalExpression:t=>"("+h(t.test)+"?"+h(t.consequent)+":"+h(t.alternate)+")",LogicalExpression:t=>"("+h(t.left)+t.operator+h(t.right)+")",ObjectExpression:t=>"{"+t.properties.map(h).join(",")+"}",Property:t=>{f+=1;const e=h(t.key);return f-=1,e+":"+h(t.value)}};function p(t){const e={code:h(t),globals:Object.keys(l),fields:Object.keys(c)};return l={},c={},e}return p.functions=i,p.constants=r,p}const kT=Symbol("vega_selection_getter");function AT(t){return t.getter&&t.getter[kT]||(t.getter=l(t.field),t.getter[kT]=!0),t.getter}const MT="intersect",ET="union",DT="_vgsid_",CT=l(DT),FT="E",ST="R",$T="R-E",TT="R-LE",BT="R-RE",zT="index:unit";function NT(t,e){for(var n,r,i=e.fields,o=e.values,a=i.length,s=0;s1?e-1:0),r=1;re.includes(t))):e},R_union:function(t,e){var n=S(e[0]),r=S(e[1]);return n>r&&(n=e[1],r=e[0]),t.length?(t[0]>n&&(t[0]=n),t[1]r&&(n=e[1],r=e[0]),t.length?rr&&(t[1]=r),t):[n,r]}};function qT(t,e,n,r){e[0].type!==BS&&s("First argument to selection functions must be a string literal.");const i=e[0].value,o="unit",a="@"+o,u=":"+i;(e.length>=2&&F(e).value)!==MT||lt(r,a)||(r[a]=n.getData(i).indataRef(n,o)),lt(r,u)||(r[u]=n.getData(i).tuplesRef())}function PT(t){const e=this.context.data[t];return e?e.values.value:[]}const jT=t=>function(e,n){return this.context.dataflow.locale()[t](n)(e)},IT=jT("format"),WT=jT("timeFormat"),HT=jT("utcFormat"),YT=jT("timeParse"),GT=jT("utcParse"),VT=new Date(2e3,0,1);function XT(t,e,n){return Number.isInteger(t)&&Number.isInteger(e)?(VT.setYear(2e3),VT.setMonth(t),VT.setDate(e),WT.call(this,VT,n)):""}const JT="%",ZT="$";function QT(t,e,n,r){e[0].type!==BS&&s("First argument to data functions must be a string literal.");const i=e[0].value,o=":"+i;if(!lt(o,r))try{r[o]=n.getData(i).tuplesRef()}catch(t){}}function KT(t,e,n,r){if(e[0].type===BS)tB(n,r,e[0].value);else for(t in n.scales)tB(n,r,t)}function tB(t,e,n){const r=JT+n;if(!lt(e,r))try{e[r]=t.scaleRef(n)}catch(t){}}function eB(t,e){if(J(t))return t;if(xt(t)){const n=e.scales[t];return n&&function(t){return t&&!0===t[ip]}(n.value)?n.value:void 0}}function nB(t,e,n){e.__bandwidth=t=>t&&t.bandwidth?t.bandwidth():0,n._bandwidth=KT,n._range=KT,n._scale=KT;const r=e=>"_["+(e.type===BS?Ct(JT+e.value):Ct(JT)+"+"+t(e))+"]";return{_bandwidth:t=>`this.__bandwidth(${r(t[0])})`,_range:t=>`${r(t[0])}.range()`,_scale:e=>`${r(e[0])}(${t(e[1])})`}}function rB(t,e){return function(n,r,i){if(n){const e=eB(n,(i||this).context);return e&&e.path[t](r)}return e(r)}}const iB=rB("area",(function(t){return Tw=new se,pw(t,Bw),2*Tw})),oB=rB("bounds",(function(t){var e,n,r,i,o,a,s;if(kw=ww=-(xw=bw=1/0),Fw=[],pw(t,sk),n=Fw.length){for(Fw.sort(mk),e=1,o=[r=Fw[0]];egk(r[0],r[1])&&(r[1]=i[1]),gk(i[0],r[1])>gk(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,e=0,r=o[n=o.length-1];e<=n;r=i,++e)i=o[e],(s=gk(r[1],i[0]))>a&&(a=s,xw=i[0],ww=r[1])}return Fw=Sw=null,xw===1/0||bw===1/0?[[NaN,NaN],[NaN,NaN]]:[[xw,bw],[ww,kw]]})),aB=rB("centroid",(function(t){Hw=Yw=Gw=Vw=Xw=Jw=Zw=Qw=0,Kw=new se,tk=new se,ek=new se,pw(t,vk);var e=+Kw,n=+tk,r=+ek,i=Kb(e,n,r);return ifB(t,e)}const dB={};function pB(t){return k(t)||ArrayBuffer.isView(t)?t:null}function gB(t){return pB(t)||(xt(t)?t:null)}const mB=t=>t.data;function yB(t,e){const n=PT.call(e,t);return n.root&&n.root.lookup||{}}const vB=()=>"undefined"!=typeof window&&window||null;function _B(t,e,n){if(!t)return[];const[r,i]=t,o=(new Vg).set(r[0],r[1],i[0],i[1]);return B_(n||this.context.dataflow.scenegraph().root,o,function(t){let e=null;if(t){const n=V(t.marktype),r=V(t.markname);e=t=>(!n.length||n.some((e=>t.marktype===e)))&&(!r.length||r.some((e=>t.name===e)))}return e}(e))}const xB={random:()=>t.random(),cumulativeNormal:hs,cumulativeLogNormal:vs,cumulativeUniform:As,densityNormal:fs,densityLogNormal:ys,densityUniform:ks,quantileNormal:ds,quantileLogNormal:_s,quantileUniform:Ms,sampleNormal:cs,sampleLogNormal:ms,sampleUniform:ws,isArray:k,isBoolean:gt,isDate:mt,isDefined:t=>void 0!==t,isNumber:vt,isObject:A,isRegExp:_t,isString:xt,isTuple:ma,isValid:t=>null!=t&&t==t,toBoolean:Ft,toDate:t=>$t(t),toNumber:S,toString:Tt,indexof:function(t){for(var e=arguments.length,n=new Array(e>1?e-1:0),r=1;r1?e-1:0),r=1;r1?e-1:0),r=1;r1?e-1:0),r=1;rat(t),inScope:function(t){const e=this.context.group;let n=!1;if(e)for(;t;){if(t===e){n=!0;break}t=t.mark.group}return n},intersect:_B,clampRange:X,pinchDistance:function(t){const e=t.touches,n=e[0].clientX-e[1].clientX,r=e[0].clientY-e[1].clientY;return Math.hypot(n,r)},pinchAngle:function(t){const e=t.touches;return Math.atan2(e[0].clientY-e[1].clientY,e[0].clientX-e[1].clientX)},screen:function(){const t=vB();return t?t.screen:{}},containerSize:function(){const t=this.context.dataflow,e=t.container&&t.container();return e?[e.clientWidth,e.clientHeight]:[void 0,void 0]},windowSize:function(){const t=vB();return t?[t.innerWidth,t.innerHeight]:[void 0,void 0]},bandspace:function(t,e,n){return $d(t||0,e||0,n||0)},setdata:function(t,e){const n=this.context.dataflow,r=this.context.data[t].input;return n.pulse(r,n.changeset().remove(p).insert(e)),1},pathShape:function(t){let e=null;return function(n){return n?yg(n,e=e||ag(t)):t}},panLinear:R,panLog:U,panPow:L,panSymlog:q,zoomLinear:j,zoomLog:I,zoomPow:W,zoomSymlog:H,encode:function(t,e,n){if(t){const n=this.context.dataflow,r=t.mark.source;n.pulse(r,n.changeset().encode(t,e))}return void 0!==n?n:t},modify:function(t,e,n,r,i,o){const a=this.context.dataflow,s=this.context.data[t],u=s.input,l=a.stamp();let c,f,h=s.changes;if(!1===a._trigger||!(u.value.length||e||r))return 0;if((!h||h.stamp{s.modified=!0,a.pulse(u,h).run()}),!0,1)),n&&(c=!0===n?p:k(n)||ma(n)?n:hB(n),h.remove(c)),e&&h.insert(e),r&&(c=hB(r),u.value.some(c)?h.remove(c):h.insert(r)),i)for(f in o)h.modify(i,f,o[f]);return 1},lassoAppend:function(t,e,n){let r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:5;const i=(t=V(t))[t.length-1];return void 0===i||Math.hypot(i[0]-e,i[1]-n)>r?[...t,[e,n]]:t},lassoPath:function(t){return V(t).reduce(((e,n,r)=>{let[i,o]=n;return e+(0==r?`M ${i},${o} `:r===t.length-1?" Z":`L ${i},${o} `)}),"")},intersectLasso:function(t,e,n){const{x:r,y:i,mark:o}=n,a=(new Vg).set(Number.MAX_SAFE_INTEGER,Number.MAX_SAFE_INTEGER,Number.MIN_SAFE_INTEGER,Number.MIN_SAFE_INTEGER);for(const[t,n]of e)ta.x2&&(a.x2=t),na.y2&&(a.y2=n);return a.translate(r,i),_B([[a.x1,a.y1],[a.x2,a.y2]],t,o).filter((t=>function(t,e,n){let r=0;for(let i=0,o=n.length-1;ie!=s>e&&t<(a-u)*(e-l)/(s-l)+u&&r++}return 1&r}(t.x,t.y,e)))}},bB=["view","item","group","xy","x","y"],wB="this.",kB={},AB={forbidden:["_"],allowed:["datum","event","item"],fieldvar:"datum",globalvar:t=>`_[${Ct(ZT+t)}]`,functions:function(t){const e=bT(t);bB.forEach((t=>e[t]="event.vega."+t));for(const t in xB)e[t]=wB+t;return ot(e,nB(t,xB,kB)),e},constants:xT,visitors:kB},MB=wT(AB);function EB(t,e,n){return 1===arguments.length?xB[t]:(xB[t]=e,n&&(kB[t]=n),MB&&(MB.functions[t]=wB+t),this)}function DB(t,e){const n={};let r;try{r=_T(t=xt(t)?t:Ct(t)+"")}catch(e){s("Expression parse error: "+t)}r.visit((t=>{if(t.type!==RS)return;const r=t.callee.name,i=AB.visitors[r];i&&i(r,t.arguments,e,n)}));const i=MB(r);return i.globals.forEach((t=>{const r=ZT+t;!lt(n,r)&&e.getSignal(t)&&(n[r]=e.signalRef(t))})),{$expr:ot({code:i.code},e.options.ast?{ast:r}:null),$fields:i.fields,$params:n}}EB("bandwidth",(function(t,e){const n=eB(t,(e||this).context);return n&&n.bandwidth?n.bandwidth():0}),KT),EB("copy",(function(t,e){const n=eB(t,(e||this).context);return n?n.copy():void 0}),KT),EB("domain",(function(t,e){const n=eB(t,(e||this).context);return n?n.domain():[]}),KT),EB("range",(function(t,e){const n=eB(t,(e||this).context);return n&&n.range?n.range():[]}),KT),EB("invert",(function(t,e,n){const r=eB(t,(n||this).context);return r?k(e)?(r.invertRange||r.invert)(e):(r.invert||r.invertExtent)(e):void 0}),KT),EB("scale",(function(t,e,n){const r=eB(t,(n||this).context);return r?r(e):void 0}),KT),EB("gradient",(function(t,e,n,r,i){t=eB(t,(i||this).context);const o=Qp(e,n);let a=t.domain(),s=a[0],u=F(a),l=f;return u-s?l=_p(t,s,u):t=(t.interpolator?ap("sequential")().interpolator(t.interpolator()):ap("linear")().interpolate(t.interpolate()).range(t.range())).domain([s=0,u=1]),t.ticks&&(a=t.ticks(+r||15),s!==a[0]&&a.unshift(s),u!==F(a)&&a.push(u)),a.forEach((e=>o.stop(l(e),t(e)))),o}),KT),EB("geoArea",iB,KT),EB("geoBounds",oB,KT),EB("geoCentroid",aB,KT),EB("geoShape",(function(t,e,n){const r=eB(t,(n||this).context);return function(t){return r?r.path.context(t)(e):""}}),KT),EB("geoScale",(function(t,e){const n=eB(t,(e||this).context);return n&&n.scale()}),KT),EB("indata",(function(t,e,n){const r=this.context.data[t]["index:"+e],i=r?r.value.get(n):void 0;return i?i.count:i}),(function(t,e,n,r){e[0].type!==BS&&s("First argument to indata must be a string literal."),e[1].type!==BS&&s("Second argument to indata must be a string literal.");const i=e[0].value,o=e[1].value,a="@"+o;lt(a,r)||(r[a]=n.getData(i).indataRef(n,o))})),EB("data",PT,QT),EB("treePath",(function(t,e,n){const r=yB(t,this),i=r[e],o=r[n];return i&&o?i.path(o).map(mB):void 0}),QT),EB("treeAncestors",(function(t,e){const n=yB(t,this)[e];return n?n.ancestors().map(mB):void 0}),QT),EB("vlSelectionTest",(function(t,e,n){for(var r,i,o,a,s,u=this.context.data[t],l=u?u.values.value:[],c=u?u[zT]&&u[zT].value:void 0,f=n===MT,h=l.length,d=0;d(t[o[n].field]=e,t)),{}))}else u=DT,l=CT(i),(f=(c=v[u]||(v[u]={}))[s]||(c[s]=[])).push(l),n&&(f=_[s]||(_[s]=[])).push({[DT]:l});if(e=e||ET,v[DT]?v[DT]=LT[`${DT}_${e}`](...Object.values(v[DT])):Object.keys(v).forEach((t=>{v[t]=Object.keys(v[t]).map((e=>v[t][e])).reduce(((n,r)=>void 0===n?r:LT[`${x[t]}_${e}`](n,r)))})),y=Object.keys(_),n&&y.length){v[r?"vlPoint":"vlMulti"]=e===ET?{or:y.reduce(((t,e)=>(t.push(..._[e]),t)),[])}:{and:y.map((t=>({or:_[t]})))}}return v}),qT),EB("vlSelectionTuples",(function(t,e){return t.map((t=>ot(e.fields?{values:e.fields.map((e=>AT(e)(t.datum)))}:{[DT]:CT(t.datum)},e)))}));const CB=Bt(["rule"]),FB=Bt(["group","image","rect"]);function SB(t){return(t+"").toLowerCase()}function $B(t,e,n){n.endsWith(";")||(n="return("+n+");");const r=Function(...e.concat(n));return t&&t.functions?r.bind(t.functions):r}var TB={operator:(t,e)=>$B(t,["_"],e.code),parameter:(t,e)=>$B(t,["datum","_"],e.code),event:(t,e)=>$B(t,["event"],e.code),handler:(t,e)=>$B(t,["_","event"],`var datum=event.item&&event.item.datum;return ${e.code};`),encode:(t,e)=>{const{marktype:n,channels:r}=e;let i="var o=item,datum=o.datum,m=0,$;";for(const t in r){const e="o["+Ct(t)+"]";i+=`$=${r[t].code};if(${e}!==$)${e}=$,m=1;`}return i+=function(t,e){let n="";return CB[e]||(t.x2&&(t.x?(FB[e]&&(n+="if(o.x>o.x2)$=o.x,o.x=o.x2,o.x2=$;"),n+="o.width=o.x2-o.x;"):n+="o.x=o.x2-(o.width||0);"),t.xc&&(n+="o.x=o.xc-(o.width||0)/2;"),t.y2&&(t.y?(FB[e]&&(n+="if(o.y>o.y2)$=o.y,o.y=o.y2,o.y2=$;"),n+="o.height=o.y2-o.y;"):n+="o.y=o.y2-(o.height||0);"),t.yc&&(n+="o.y=o.yc-(o.height||0)/2;")),n}(r,n),i+="return m;",$B(t,["item","_"],i)},codegen:{get(t){const e=`[${t.map(Ct).join("][")}]`,n=Function("_",`return _${e};`);return n.path=e,n},comparator(t,e){let n;const r=Function("a","b","var u, v; return "+t.map(((t,r)=>{const i=e[r];let o,a;return t.path?(o=`a${t.path}`,a=`b${t.path}`):((n=n||{})["f"+r]=t,o=`this.f${r}(a)`,a=`this.f${r}(b)`),function(t,e,n,r){return`((u = ${t}) < (v = ${e}) || u == null) && v != null ? ${n}\n : (u > v || v == null) && u != null ? ${r}\n : ((v = v instanceof Date ? +v : v), (u = u instanceof Date ? +u : u)) !== u && v === v ? ${n}\n : v !== v && u === u ? ${r} : `}(o,a,-i,i)})).join("")+"0;");return n?r.bind(n):r}}};function BB(t,e,n){if(!t||!A(t))return t;for(let r,i=0,o=zB.length;it&&t.$tupleid?ya:t));return e.fn[n]||(e.fn[n]=Q(r,t.$order,e.expr.codegen))}},{key:"$context",parse:function(t,e){return e}},{key:"$subflow",parse:function(t,e){const n=t.$subflow;return function(t,r,i){const o=e.fork().parse(n),a=o.get(n.operators[0].id),s=o.signals.parent;return s&&s.set(i),a.detachSubflow=()=>e.detach(o),a}}},{key:"$tupleid",parse:function(){return ya}}];const NB={skip:!0};function OB(t,e,n,r){return new RB(t,e,n,r)}function RB(t,e,n,r){this.dataflow=t,this.transforms=e,this.events=t.events.bind(t),this.expr=r||TB,this.signals={},this.scales={},this.nodes={},this.data={},this.fn={},n&&(this.functions=Object.create(n),this.functions.context=this)}function UB(t){this.dataflow=t.dataflow,this.transforms=t.transforms,this.events=t.events,this.expr=t.expr,this.signals=Object.create(t.signals),this.scales=Object.create(t.scales),this.nodes=Object.create(t.nodes),this.data=Object.create(t.data),this.fn=Object.create(t.fn),t.functions&&(this.functions=Object.create(t.functions),this.functions.context=this)}function LB(t,e){t&&(null==e?t.removeAttribute("aria-label"):t.setAttribute("aria-label",e))}RB.prototype=UB.prototype={fork(){const t=new UB(this);return(this.subcontext||(this.subcontext=[])).push(t),t},detach(t){this.subcontext=this.subcontext.filter((e=>e!==t));const e=Object.keys(t.nodes);for(const n of e)t.nodes[n]._targets=null;for(const n of e)t.nodes[n].detach();t.nodes=null},get(t){return this.nodes[t]},set(t,e){return this.nodes[t]=e},add(t,e){const n=this,r=n.dataflow,i=t.value;if(n.set(t.id,e),function(t){return"collect"===SB(t)}(t.type)&&i&&(i.$ingest?r.ingest(e,i.$ingest,i.$format):i.$request?r.preload(e,i.$request,i.$format):r.pulse(e,r.changeset().insert(i))),t.root&&(n.root=e),t.parent){let i=n.get(t.parent.$ref);i?(r.connect(i,[e]),e.targets().add(i)):(n.unresolved=n.unresolved||[]).push((()=>{i=n.get(t.parent.$ref),r.connect(i,[e]),e.targets().add(i)}))}if(t.signal&&(n.signals[t.signal]=e),t.scale&&(n.scales[t.scale]=e),t.data)for(const r in t.data){const i=n.data[r]||(n.data[r]={});t.data[r].forEach((t=>i[t]=e))}},resolve(){return(this.unresolved||[]).forEach((t=>t())),delete this.unresolved,this},operator(t,e){this.add(t,this.dataflow.add(t.value,e))},transform(t,e){this.add(t,this.dataflow.add(this.transforms[SB(e)]))},stream(t,e){this.set(t.id,e)},update(t,e,n,r,i){this.dataflow.on(e,n,r,i,t.options)},operatorExpression(t){return this.expr.operator(this,t)},parameterExpression(t){return this.expr.parameter(this,t)},eventExpression(t){return this.expr.event(this,t)},handlerExpression(t){return this.expr.handler(this,t)},encodeExpression(t){return this.expr.encode(this,t)},parse:function(t){const e=this,n=t.operators||[];return t.background&&(e.background=t.background),t.eventConfig&&(e.eventConfig=t.eventConfig),t.locale&&(e.locale=t.locale),n.forEach((t=>e.parseOperator(t))),n.forEach((t=>e.parseOperatorParameters(t))),(t.streams||[]).forEach((t=>e.parseStream(t))),(t.updates||[]).forEach((t=>e.parseUpdate(t))),e.resolve()},parseOperator:function(t){const e=this;!function(t){return"operator"===SB(t)}(t.type)&&t.type?e.transform(t,t.type):e.operator(t,t.update?e.operatorExpression(t.update):null)},parseOperatorParameters:function(t){const e=this;if(t.params){const n=e.get(t.id);n||s("Invalid operator id: "+t.id),e.dataflow.connect(n,n.parameters(e.parseParameters(t.params),t.react,t.initonly))}},parseParameters:function(t,e){e=e||{};const n=this;for(const r in t){const i=t[r];e[r]=k(i)?i.map((t=>BB(t,n,e))):BB(i,n,e)}return e},parseStream:function(t){var e,n=this,r=null!=t.filter?n.eventExpression(t.filter):void 0,i=null!=t.stream?n.get(t.stream):void 0;t.source?i=n.events(t.source,t.type,r):t.merge&&(i=(e=t.merge.map((t=>n.get(t))))[0].merge.apply(e[0],e.slice(1))),t.between&&(e=t.between.map((t=>n.get(t))),i=i.between(e[0],e[1])),t.filter&&(i=i.filter(r)),null!=t.throttle&&(i=i.throttle(+t.throttle)),null!=t.debounce&&(i=i.debounce(+t.debounce)),null==i&&s("Invalid stream definition: "+JSON.stringify(t)),t.consume&&i.consume(!0),n.stream(t,i)},parseUpdate:function(t){var e,n=this,r=A(r=t.source)?r.$ref:r,i=n.get(r),o=t.update,a=void 0;i||s("Source not defined: "+t.source),e=t.target&&t.target.$expr?n.eventExpression(t.target.$expr):n.get(t.target),o&&o.$expr&&(o.$params&&(a=n.parseParameters(o.$params)),o=n.handlerExpression(o.$expr)),n.update(t,i,e,o,a)},getState:function(t){var e=this,n={};if(t.signals){var r=n.signals={};Object.keys(e.signals).forEach((n=>{const i=e.signals[n];t.signals(n,i)&&(r[n]=i.value)}))}if(t.data){var i=n.data={};Object.keys(e.data).forEach((n=>{const r=e.data[n];t.data(n,r)&&(i[n]=r.input.value)}))}return e.subcontext&&!1!==t.recurse&&(n.subcontext=e.subcontext.map((e=>e.getState(t)))),n},setState:function(t){var e=this,n=e.dataflow,r=t.data,i=t.signals;Object.keys(i||{}).forEach((t=>{n.update(e.signals[t],i[t],NB)})),Object.keys(r||{}).forEach((t=>{n.pulse(e.data[t].input,n.changeset().remove(p).insert(r[t]))})),(t.subcontext||[]).forEach(((t,n)=>{const r=e.subcontext[n];r&&r.setState(t)}))}};const qB="default";function PB(t,e){const n=t.globalCursor()?"undefined"!=typeof document&&document.body:t.container();if(n)return null==e?n.style.removeProperty("cursor"):n.style.cursor=e}function jB(t,e){var n=t._runtime.data;return lt(n,e)||s("Unrecognized data set: "+e),n[e]}function IB(t,e){Aa(e)||s("Second argument to changes must be a changeset.");const n=jB(this,t);return n.modified=!0,this.pulse(n.input,e)}function WB(t){var e=t.padding();return Math.max(0,t._viewWidth+e.left+e.right)}function HB(t){var e=t.padding();return Math.max(0,t._viewHeight+e.top+e.bottom)}function YB(t){var e=t.padding(),n=t._origin;return[e.left+n[0],e.top+n[1]]}function GB(t,e,n){var r,i,o=t._renderer,a=o&&o.canvas();return a&&(i=YB(t),(r=av(e.changedTouches?e.changedTouches[0]:e,a))[0]-=i[0],r[1]-=i[1]),e.dataflow=t,e.item=n,e.vega=function(t,e,n){const r=e?"group"===e.mark.marktype?e:e.mark.group:null;function i(t){var n,i=r;if(t)for(n=e;n;n=n.mark.group)if(n.mark.name===t){i=n;break}return i&&i.mark&&i.mark.interactive?i:{}}function o(t){if(!t)return n;xt(t)&&(t=i(t));const e=n.slice();for(;t;)e[0]-=t.x||0,e[1]-=t.y||0,t=t.mark&&t.mark.group;return e}return{view:rt(t),item:rt(e||{}),group:i,xy:o,x:t=>o(t)[0],y:t=>o(t)[1]}}(t,n,r),e}const VB="view",XB={trap:!1};function JB(t,e,n,r){t._eventListeners.push({type:n,sources:V(e),handler:r})}function ZB(t,e,n){const r=t._eventConfig&&t._eventConfig[e];return!(!1===r||A(r)&&!r[n])||(t.warn(`Blocked ${e} ${n} event listener.`),!1)}function QB(t){return t.item}function KB(t){return t.item.mark.source}function tz(t){return function(e,n){return n.vega.view().changeset().encode(n.item,t)}}function ez(t,e,n){const r=document.createElement(t);for(const t in e)r.setAttribute(t,e[t]);return null!=n&&(r.textContent=n),r}const nz="vega-bind",rz="vega-bind-name",iz="vega-bind-radio";function oz(t,e,n,r){const i=n.event||"input",o=()=>t.update(e.value);r.signal(n.signal,e.value),e.addEventListener(i,o),JB(r,e,i,o),t.set=t=>{e.value=t,e.dispatchEvent(function(t){return"undefined"!=typeof Event?new Event(t):{type:t}}(i))}}function az(t,e,n,r){const i=r.signal(n.signal),o=ez("div",{class:nz}),a="radio"===n.input?o:o.appendChild(ez("label"));a.appendChild(ez("span",{class:rz},n.name||n.signal)),e.appendChild(o);let s=sz;switch(n.input){case"checkbox":s=uz;break;case"select":s=lz;break;case"radio":s=cz;break;case"range":s=fz}s(t,a,n,i)}function sz(t,e,n,r){const i=ez("input");for(const t in n)"signal"!==t&&"element"!==t&&i.setAttribute("input"===t?"type":t,n[t]);i.setAttribute("name",n.signal),i.value=r,e.appendChild(i),i.addEventListener("input",(()=>t.update(i.value))),t.elements=[i],t.set=t=>i.value=t}function uz(t,e,n,r){const i={type:"checkbox",name:n.signal};r&&(i.checked=!0);const o=ez("input",i);e.appendChild(o),o.addEventListener("change",(()=>t.update(o.checked))),t.elements=[o],t.set=t=>o.checked=!!t||null}function lz(t,e,n,r){const i=ez("select",{name:n.signal}),o=n.labels||[];n.options.forEach(((t,e)=>{const n={value:t};hz(t,r)&&(n.selected=!0),i.appendChild(ez("option",n,(o[e]||t)+""))})),e.appendChild(i),i.addEventListener("change",(()=>{t.update(n.options[i.selectedIndex])})),t.elements=[i],t.set=t=>{for(let e=0,r=n.options.length;e{const s={type:"radio",name:n.signal,value:e};hz(e,r)&&(s.checked=!0);const u=ez("input",s);u.addEventListener("change",(()=>t.update(e)));const l=ez("label",{},(o[a]||e)+"");return l.prepend(u),i.appendChild(l),u})),t.set=e=>{const n=t.elements,r=n.length;for(let t=0;t{u.textContent=s.value,t.update(+s.value)};s.addEventListener("input",l),s.addEventListener("change",l),t.elements=[s],t.set=t=>{s.value=t,u.textContent=t}}function hz(t,e){return t===e||t+""==e+""}function dz(t,e,n,r,i,o){return(e=e||new r(t.loader())).initialize(n,WB(t),HB(t),YB(t),i,o).background(t.background())}function pz(t,e){return e?function(){try{e.apply(this,arguments)}catch(e){t.error(e)}}:null}function gz(t,e,n){if("string"==typeof e){if("undefined"==typeof document)return t.error("DOM document instance not found."),null;if(!(e=document.querySelector(e)))return t.error("Signal bind element not found: "+e),null}if(e&&n)try{e.textContent=""}catch(n){e=null,t.error(n)}return e}const mz=t=>+t||0;function yz(t){return A(t)?{top:mz(t.top),bottom:mz(t.bottom),left:mz(t.left),right:mz(t.right)}:(t=>({top:t,bottom:t,left:t,right:t}))(mz(t))}async function vz(t,e,n,r){const i=T_(e),o=i&&i.headless;return o||s("Unrecognized renderer type: "+e),await t.runAsync(),dz(t,null,null,o,n,r).renderAsync(t._scenegraph.root)}var _z="width",xz="height",bz="padding",wz={skip:!0};function kz(t,e){var n=t.autosize(),r=t.padding();return e-(n&&n.contains===bz?r.left+r.right:0)}function Az(t,e){var n=t.autosize(),r=t.padding();return e-(n&&n.contains===bz?r.top+r.bottom:0)}function Mz(t,e){return e.modified&&k(e.input.value)&&!t.startsWith("_:vega:_")}function Ez(t,e){return!("parent"===t||e instanceof Za.proxy)}function Dz(t,e,n,r){const i=t.element();i&&i.setAttribute("title",function(t){return null==t?"":k(t)?Cz(t):A(t)&&!mt(t)?(e=t,Object.keys(e).map((t=>{const n=e[t];return t+": "+(k(n)?Cz(n):Fz(n))})).join("\n")):t+"";var e}(r))}function Cz(t){return"["+t.map(Fz).join(", ")+"]"}function Fz(t){return k(t)?"[…]":A(t)&&!mt(t)?"{…}":t}function Sz(t,e){const n=this;if(e=e||{},Va.call(n),e.loader&&n.loader(e.loader),e.logger&&n.logger(e.logger),null!=e.logLevel&&n.logLevel(e.logLevel),e.locale||t.locale){const r=ot({},t.locale,e.locale);n.locale(Ro(r.number,r.time))}n._el=null,n._elBind=null,n._renderType=e.renderer||S_.Canvas,n._scenegraph=new Ky;const r=n._scenegraph.root;n._renderer=null,n._tooltip=e.tooltip||Dz,n._redraw=!0,n._handler=(new Sv).scene(r),n._globalCursor=!1,n._preventDefault=!1,n._timers=[],n._eventListeners=[],n._resizeListeners=[],n._eventConfig=function(t){const e=ot({defaults:{}},t),n=(t,e)=>{e.forEach((e=>{k(t[e])&&(t[e]=Bt(t[e]))}))};return n(e.defaults,["prevent","allow"]),n(e,["view","window","selector"]),e}(t.eventConfig),n.globalCursor(n._eventConfig.globalCursor);const i=function(t,e,n){return OB(t,Za,xB,n).parse(e)}(n,t,e.expr);n._runtime=i,n._signals=i.signals,n._bind=(t.bindings||[]).map((t=>({state:null,param:ot({},t)}))),i.root&&i.root.set(r),r.source=i.data.root.input,n.pulse(i.data.root.input,n.changeset().insert(r.items)),n._width=n.width(),n._height=n.height(),n._viewWidth=kz(n,n._width),n._viewHeight=Az(n,n._height),n._origin=[0,0],n._resize=0,n._autosize=1,function(t){var e=t._signals,n=e[_z],r=e[xz],i=e[bz];function o(){t._autosize=t._resize=1}t._resizeWidth=t.add(null,(e=>{t._width=e.size,t._viewWidth=kz(t,e.size),o()}),{size:n}),t._resizeHeight=t.add(null,(e=>{t._height=e.size,t._viewHeight=Az(t,e.size),o()}),{size:r});const a=t.add(null,o,{pad:i});t._resizeWidth.rank=n.rank+1,t._resizeHeight.rank=r.rank+1,a.rank=i.rank+1}(n),function(t){t.add(null,(e=>(t._background=e.bg,t._resize=1,e.bg)),{bg:t._signals.background})}(n),function(t){const e=t._signals.cursor||(t._signals.cursor=t.add({user:qB,item:null}));t.on(t.events("view","pointermove"),e,((t,n)=>{const r=e.value,i=r?xt(r)?r:r.user:qB,o=n.item&&n.item.cursor||null;return r&&i===r.user&&o==r.item?r:{user:i,item:o}})),t.add(null,(function(e){let n=e.cursor,r=this.value;return xt(n)||(r=n.item,n=n.user),PB(t,n&&n!==qB?n:r||n),r}),{cursor:e})}(n),n.description(t.description),e.hover&&n.hover(),e.container&&n.initialize(e.container,e.bind),e.watchPixelRatio&&n._watchPixelRatio()}function $z(t,e){return lt(t._signals,e)?t._signals[e]:s("Unrecognized signal name: "+Ct(e))}function Tz(t,e){const n=(t._targets||[]).filter((t=>t._update&&t._update.handler===e));return n.length?n[0]:null}function Bz(t,e,n,r){let i=Tz(n,r);return i||(i=pz(t,(()=>r(e,n.value))),i.handler=r,t.on(n,null,i)),t}function zz(t,e,n){const r=Tz(e,n);return r&&e._targets.remove(r),t}dt(Sz,Va,{async evaluate(t,e,n){if(await Va.prototype.evaluate.call(this,t,e),this._redraw||this._resize)try{this._renderer&&(this._resize&&(this._resize=0,function(t){var e=YB(t),n=WB(t),r=HB(t);t._renderer.background(t.background()),t._renderer.resize(n,r,e),t._handler.origin(e),t._resizeListeners.forEach((e=>{try{e(n,r)}catch(e){t.error(e)}}))}(this)),await this._renderer.renderAsync(this._scenegraph.root)),this._redraw=!1}catch(t){this.error(t)}return n&&da(this,n),this},dirty(t){this._redraw=!0,this._renderer&&this._renderer.dirty(t)},description(t){if(arguments.length){const e=null!=t?t+"":null;return e!==this._desc&&LB(this._el,this._desc=e),this}return this._desc},container(){return this._el},scenegraph(){return this._scenegraph},origin(){return this._origin.slice()},signal(t,e,n){const r=$z(this,t);return 1===arguments.length?r.value:this.update(r,e,n)},width(t){return arguments.length?this.signal("width",t):this.signal("width")},height(t){return arguments.length?this.signal("height",t):this.signal("height")},padding(t){return arguments.length?this.signal("padding",yz(t)):yz(this.signal("padding"))},autosize(t){return arguments.length?this.signal("autosize",t):this.signal("autosize")},background(t){return arguments.length?this.signal("background",t):this.signal("background")},renderer(t){return arguments.length?(T_(t)||s("Unrecognized renderer type: "+t),t!==this._renderType&&(this._renderType=t,this._resetRenderer()),this):this._renderType},tooltip(t){return arguments.length?(t!==this._tooltip&&(this._tooltip=t,this._resetRenderer()),this):this._tooltip},loader(t){return arguments.length?(t!==this._loader&&(Va.prototype.loader.call(this,t),this._resetRenderer()),this):this._loader},resize(){return this._autosize=1,this.touch($z(this,"autosize"))},_resetRenderer(){this._renderer&&(this._renderer=null,this.initialize(this._el,this._elBind))},_resizeView:function(t,e,n,r,i,o){this.runAfter((a=>{let s=0;a._autosize=0,a.width()!==n&&(s=1,a.signal(_z,n,wz),a._resizeWidth.skip(!0)),a.height()!==r&&(s=1,a.signal(xz,r,wz),a._resizeHeight.skip(!0)),a._viewWidth!==t&&(a._resize=1,a._viewWidth=t),a._viewHeight!==e&&(a._resize=1,a._viewHeight=e),a._origin[0]===i[0]&&a._origin[1]===i[1]||(a._resize=1,a._origin=i),s&&a.run("enter"),o&&a.runAfter((t=>t.resize()))}),!1,1)},addEventListener(t,e,n){let r=e;return n&&!1===n.trap||(r=pz(this,e),r.raw=e),this._handler.on(t,r),this},removeEventListener(t,e){for(var n,r,i=this._handler.handlers(t),o=i.length;--o>=0;)if(r=i[o].type,n=i[o].handler,t===r&&(e===n||e===n.raw)){this._handler.off(r,n);break}return this},addResizeListener(t){const e=this._resizeListeners;return e.includes(t)||e.push(t),this},removeResizeListener(t){var e=this._resizeListeners,n=e.indexOf(t);return n>=0&&e.splice(n,1),this},addSignalListener(t,e){return Bz(this,t,$z(this,t),e)},removeSignalListener(t,e){return zz(this,$z(this,t),e)},addDataListener(t,e){return Bz(this,t,jB(this,t).values,e)},removeDataListener(t,e){return zz(this,jB(this,t).values,e)},globalCursor(t){if(arguments.length){if(this._globalCursor!==!!t){const e=PB(this,null);this._globalCursor=!!t,e&&PB(this,e)}return this}return this._globalCursor},preventDefault(t){return arguments.length?(this._preventDefault=t,this):this._preventDefault},timer:function(t,e){this._timers.push(function(t,e,n){var r=new lD,i=e;return null==e?(r.restart(t,e,n),r):(r._restart=r.restart,r.restart=function(t,e,n){e=+e,n=null==n?sD():+n,r._restart((function o(a){a+=i,r._restart(o,i+=e,n),t(a)}),e,n)},r.restart(t,e,n),r)}((function(e){t({timestamp:Date.now(),elapsed:e})}),e))},events:function(t,e,n){var r,i=this,o=new Ba(n),a=function(n,r){i.runAsync(null,(()=>{t===VB&&function(t,e){var n=t._eventConfig.defaults,r=n.prevent,i=n.allow;return!1!==r&&!0!==i&&(!0===r||!1===i||(r?r[e]:i?!i[e]:t.preventDefault()))}(i,e)&&n.preventDefault(),o.receive(GB(i,n,r))}))};if("timer"===t)ZB(i,"timer",e)&&i.timer(a,e);else if(t===VB)ZB(i,"view",e)&&i.addEventListener(e,a,XB);else if("window"===t?ZB(i,"window",e)&&"undefined"!=typeof window&&(r=[window]):"undefined"!=typeof document&&ZB(i,"selector",e)&&(r=Array.from(document.querySelectorAll(t))),r){for(var s=0,u=r.length;s=0;)a[t].stop();for(t=u.length;--t>=0;)for(e=(n=u[t]).sources.length;--e>=0;)n.sources[e].removeEventListener(n.type,n.handler);for(o&&o.call(this,this._handler,null,null,null),t=s.length;--t>=0;)i=s[t].type,r=s[t].handler,this._handler.off(i,r);return this},hover:function(t,e){return e=[e||"update",(t=[t||"hover"])[0]],this.on(this.events("view","pointerover",QB),KB,tz(t)),this.on(this.events("view","pointerout",QB),KB,tz(e)),this},data:function(t,e){return arguments.length<2?jB(this,t).values.value:IB.call(this,t,Ma().remove(p).insert(e))},change:IB,insert:function(t,e){return IB.call(this,t,Ma().insert(e))},remove:function(t,e){return IB.call(this,t,Ma().remove(e))},scale:function(t){var e=this._runtime.scales;return lt(e,t)||s("Unrecognized scale or projection: "+t),e[t].value},initialize:function(t,e){const n=this,r=n._renderType,i=n._eventConfig.bind,o=T_(r);t=n._el=t?gz(n,t,!0):null,function(t){const e=t.container();e&&(e.setAttribute("role","graphics-document"),e.setAttribute("aria-roleDescription","visualization"),LB(e,t.description()))}(n),o||n.error("Unrecognized renderer type: "+r);const a=o.handler||Sv,s=t?o.renderer:o.headless;return n._renderer=s?dz(n,n._renderer,t,s):null,n._handler=function(t,e,n,r){const i=new r(t.loader(),pz(t,t.tooltip())).scene(t.scenegraph().root).initialize(n,YB(t),t);return e&&e.handlers().forEach((t=>{i.on(t.type,t.handler)})),i}(n,n._handler,t,a),n._redraw=!0,t&&"none"!==i&&(e=e?n._elBind=gz(n,e,!0):t.appendChild(ez("form",{class:"vega-bindings"})),n._bind.forEach((t=>{t.param.element&&"container"!==i&&(t.element=gz(n,t.param.element,!!t.param.input))})),n._bind.forEach((t=>{!function(t,e,n){if(!e)return;const r=n.param;let i=n.state;i||(i=n.state={elements:null,active:!1,set:null,update:e=>{e!=t.signal(r.signal)&&t.runAsync(null,(()=>{i.source=!0,t.signal(r.signal,e)}))}},r.debounce&&(i.update=it(r.debounce,i.update))),(null==r.input&&r.element?oz:az)(i,e,r,t),i.active||(t.on(t._signals[r.signal],null,(()=>{i.source?i.source=!1:i.set(t.signal(r.signal))})),i.active=!0)}(n,t.element||e,t)}))),n},toImageURL:async function(t,e){t!==S_.Canvas&&t!==S_.SVG&&t!==S_.PNG&&s("Unrecognized image type: "+t);const n=await vz(this,t,e);return t===S_.SVG?function(t,e){const n=new Blob([t],{type:e});return window.URL.createObjectURL(n)}(n.svg(),"image/svg+xml"):n.canvas().toDataURL("image/png")},toCanvas:async function(t,e){return(await vz(this,S_.Canvas,t,e)).canvas()},toSVG:async function(t){return(await vz(this,S_.SVG,t)).svg()},getState:function(t){return this._runtime.getState(t||{data:Mz,signals:Ez,recurse:!0})},setState:function(t){return this.runAsync(null,(e=>{e._trigger=!1,e._runtime.setState(t)}),(t=>{t._trigger=!0})),this},_watchPixelRatio:function(){if("canvas"===this.renderer()&&this._renderer._canvas){let t=null;const e=()=>{null!=t&&t();const n=matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);n.addEventListener("change",e),t=()=>{n.removeEventListener("change",e)},this._renderer._canvas.getContext("2d").pixelRatio=window.devicePixelRatio||1,this._redraw=!0,this._resize=1,this.resize().runAsync()};e()}}});const Nz="view",Oz="[",Rz="]",Uz="{",Lz="}",qz=":",Pz=",",jz="@",Iz=">",Wz=/[[\]{}]/,Hz={"*":1,arc:1,area:1,group:1,image:1,line:1,path:1,rect:1,rule:1,shape:1,symbol:1,text:1,trail:1};let Yz,Gz;function Vz(t,e,n){return Yz=e||Nz,Gz=n||Hz,Jz(t.trim()).map(Zz)}function Xz(t,e,n,r,i){const o=t.length;let a,s=0;for(;e' after between selector: "+t;n=n.map(Zz);const i=Zz(t.slice(1).trim());if(i.between)return{between:n,stream:i};i.between=n;return i}(t):function(t){const e={source:Yz},n=[];let r,i,o=[0,0],a=0,s=0,u=t.length,l=0;if(t[u-1]===Lz){if(l=t.lastIndexOf(Uz),!(l>=0))throw"Unmatched right brace: "+t;try{o=function(t){const e=t.split(Pz);if(!t.length||e.length>2)throw t;return e.map((e=>{const n=+e;if(n!=n)throw t;return n}))}(t.substring(l+1,u-1))}catch(e){throw"Invalid throttle specification: "+t}u=(t=t.slice(0,l).trim()).length,l=0}if(!u)throw t;t[0]===jz&&(a=++l);r=Xz(t,l,qz),r1?(e.type=n[1],a?e.markname=n[0].slice(1):!function(t){return Gz[t]}(n[0])?e.source=n[0]:e.marktype=n[0]):e.type=n[0];"!"===e.type.slice(-1)&&(e.consume=!0,e.type=e.type.slice(0,-1));null!=i&&(e.filter=i);o[0]&&(e.throttle=o[0]);o[1]&&(e.debounce=o[1]);return e}(t)}function Qz(t){return A(t)?t:{type:t||"pad"}}const Kz=t=>+t||0,tN=t=>({top:t,bottom:t,left:t,right:t});function eN(t){return A(t)?t.signal?t:{top:Kz(t.top),bottom:Kz(t.bottom),left:Kz(t.left),right:Kz(t.right)}:tN(Kz(t))}const nN=t=>A(t)&&!k(t)?ot({},t):{value:t};function rN(t,e,n,r){if(null!=n){return A(n)&&!k(n)||k(n)&&n.length&&A(n[0])?t.update[e]=n:t[r||"enter"][e]={value:n},1}return 0}function iN(t,e,n){for(const n in e)rN(t,n,e[n]);for(const e in n)rN(t,e,n[e],"update")}function oN(t,e,n){for(const r in e)n&<(n,r)||(t[r]=ot(t[r]||{},e[r]));return t}function aN(t,e){return e&&(e.enter&&e.enter[t]||e.update&&e.update[t])}const sN="mark",uN="frame",lN="scope",cN="axis",fN="axis-domain",hN="axis-grid",dN="axis-label",pN="axis-tick",gN="axis-title",mN="legend",yN="legend-band",vN="legend-entry",_N="legend-gradient",xN="legend-label",bN="legend-symbol",wN="legend-title",kN="title",AN="title-text",MN="title-subtitle";function EN(t,e,n){t[e]=n&&n.signal?{signal:n.signal}:{value:n}}const DN=t=>xt(t)?Ct(t):t.signal?`(${t.signal})`:$N(t);function CN(t){if(null!=t.gradient)return function(t){const e=[t.start,t.stop,t.count].map((t=>null==t?null:Ct(t)));for(;e.length&&null==F(e);)e.pop();return e.unshift(DN(t.gradient)),`gradient(${e.join(",")})`}(t);let e=t.signal?`(${t.signal})`:t.color?function(t){return t.c?FN("hcl",t.h,t.c,t.l):t.h||t.s?FN("hsl",t.h,t.s,t.l):t.l||t.a?FN("lab",t.l,t.a,t.b):t.r||t.g||t.b?FN("rgb",t.r,t.g,t.b):null}(t.color):null!=t.field?$N(t.field):void 0!==t.value?Ct(t.value):void 0;return null!=t.scale&&(e=function(t,e){const n=DN(t.scale);null!=t.range?e=`lerp(_range(${n}), ${+t.range})`:(void 0!==e&&(e=`_scale(${n}, ${e})`),t.band&&(e=(e?e+"+":"")+`_bandwidth(${n})`+(1==+t.band?"":"*"+SN(t.band)),t.extra&&(e=`(datum.extra ? _scale(${n}, datum.extra.value) : ${e})`)),null==e&&(e="0"));return e}(t,e)),void 0===e&&(e=null),null!=t.exponent&&(e=`pow(${e},${SN(t.exponent)})`),null!=t.mult&&(e+=`*${SN(t.mult)}`),null!=t.offset&&(e+=`+${SN(t.offset)}`),t.round&&(e=`round(${e})`),e}const FN=(t,e,n,r)=>`(${t}(${[e,n,r].map(CN).join(",")})+'')`;function SN(t){return A(t)?"("+CN(t)+")":t}function $N(t){return TN(A(t)?t:{datum:t})}function TN(t){let e,n,r;if(t.signal)e="datum",r=t.signal;else if(t.group||t.parent){for(n=Math.max(1,t.level||1),e="item";n-- >0;)e+=".mark.group";t.parent?(r=t.parent,e+=".datum"):r=t.group}else t.datum?(e="datum",r=t.datum):s("Invalid field reference: "+Ct(t));return t.signal||(r=xt(r)?u(r).map(Ct).join("]["):TN(r)),e+"["+r+"]"}function BN(t,e,n,r,i,o){const a={};(o=o||{}).encoders={$encode:a},t=function(t,e,n,r,i){const o={},a={};let s,u,l,c;for(u in u="lineBreak","text"!==e||null==i[u]||aN(u,t)||EN(o,u,i[u]),("legend"==n||String(n).startsWith("axis"))&&(n=null),c=n===uN?i.group:n===sN?ot({},i.mark,i[e]):null,c)l=aN(u,t)||("fill"===u||"stroke"===u)&&(aN("fill",t)||aN("stroke",t)),l||EN(o,u,c[u]);for(u in V(r).forEach((e=>{const n=i.style&&i.style[e];for(const e in n)aN(e,t)||EN(o,e,n[e])})),t=ot({},t),o)c=o[u],c.signal?(s=s||{})[u]=c:a[u]=c;return t.enter=ot(a,t.enter),s&&(t.update=ot(s,t.update)),t}(t,e,n,r,i.config);for(const n in t)a[n]=zN(t[n],e,o,i);return o}function zN(t,e,n,r){const i={},o={};for(const e in t)null!=t[e]&&(i[e]=NN((a=t[e],k(a)?function(t){let e="";return t.forEach((t=>{const n=CN(t);e+=t.test?`(${t.test})?${n}:`:n})),":"===F(e)&&(e+="null"),e}(a):CN(a)),r,n,o));var a;return{$expr:{marktype:e,channels:i},$fields:Object.keys(o),$output:Object.keys(t)}}function NN(t,e,n,r){const i=DB(t,e);return i.$fields.forEach((t=>r[t]=1)),ot(n,i.$params),i.$expr}const ON="outer",RN=["value","update","init","react","bind"];function UN(t,e){s(t+' for "outer" push: '+Ct(e))}function LN(t,e){const n=t.name;if(t.push===ON)e.signals[n]||UN("No prior signal definition",n),RN.forEach((e=>{void 0!==t[e]&&UN("Invalid property ",e)}));else{const r=e.addSignal(n,t.value);!1===t.react&&(r.react=!1),t.bind&&e.addBinding(n,t.bind)}}function qN(t,e,n,r){this.id=-1,this.type=t,this.value=e,this.params=n,r&&(this.parent=r)}function PN(t,e,n,r){return new qN(t,e,n,r)}function jN(t,e){return PN("operator",t,e)}function IN(t){const e={$ref:t.id};return t.id<0&&(t.refs=t.refs||[]).push(e),e}function WN(t,e){return e?{$field:t,$name:e}:{$field:t}}const HN=WN("key");function YN(t,e){return{$compare:t,$order:e}}const GN="descending";function VN(t,e){return(t&&t.signal?"$"+t.signal:t||"")+(t&&e?"_":"")+(e&&e.signal?"$"+e.signal:e||"")}const XN="scope",JN="view";function ZN(t){return t&&t.signal}function QN(t){if(ZN(t))return!0;if(A(t))for(const e in t)if(QN(t[e]))return!0;return!1}function KN(t,e){return null!=t?t:e}function tO(t){return t&&t.signal||t}const eO="timer";function nO(t,e){return(t.merge?rO:t.stream?iO:t.type?oO:s("Invalid stream specification: "+Ct(t)))(t,e)}function rO(t,e){const n=aO({merge:t.merge.map((t=>nO(t,e)))},t,e);return e.addStream(n).id}function iO(t,e){const n=aO({stream:nO(t.stream,e)},t,e);return e.addStream(n).id}function oO(t,e){let n;t.type===eO?(n=e.event(eO,t.throttle),t={between:t.between,filter:t.filter}):n=e.event(function(t){return t===XN?JN:t||JN}(t.source),t.type);const r=aO({stream:n},t,e);return 1===Object.keys(r).length?n:e.addStream(r).id}function aO(t,e,n){let r=e.between;return r&&(2!==r.length&&s('Stream "between" parameter must have 2 entries: '+Ct(e)),t.between=[nO(r[0],n),nO(r[1],n)]),r=e.filter?[].concat(e.filter):[],(e.marktype||e.markname||e.markrole)&&r.push(function(t,e,n){const r="event.item";return r+(t&&"*"!==t?"&&"+r+".mark.marktype==='"+t+"'":"")+(n?"&&"+r+".mark.role==='"+n+"'":"")+(e?"&&"+r+".mark.name==='"+e+"'":"")}(e.marktype,e.markname,e.markrole)),e.source===XN&&r.push("inScope(event.item)"),r.length&&(t.filter=DB("("+r.join(")&&(")+")",n).$expr),null!=(r=e.throttle)&&(t.throttle=+r),null!=(r=e.debounce)&&(t.debounce=+r),e.consume&&(t.consume=!0),t}const sO={code:"_.$value",ast:{type:"Identifier",value:"value"}};function uO(t,e,n){const r=t.encode,i={target:n};let o=t.events,a=t.update,u=[];o||s("Signal update missing events specification."),xt(o)&&(o=Vz(o,e.isSubscope()?XN:JN)),o=V(o).filter((t=>t.signal||t.scale?(u.push(t),0):1)),u.length>1&&(u=[lO(u)]),o.length&&u.push(o.length>1?{merge:o}:o[0]),null!=r&&(a&&s("Signal encode and update are mutually exclusive."),a="encode(item(),"+Ct(r)+")"),i.update=xt(a)?DB(a,e):null!=a.expr?DB(a.expr,e):null!=a.value?a.value:null!=a.signal?{$expr:sO,$params:{$value:e.signalRef(a.signal)}}:s("Invalid signal update specification."),t.force&&(i.options={force:!0}),u.forEach((t=>e.addUpdate(ot(function(t,e){return{source:t.signal?e.signalRef(t.signal):t.scale?e.scaleRef(t.scale):nO(t,e)}}(t,e),i))))}function lO(t){return{signal:"["+t.map((t=>t.scale?'scale("'+t.scale+'")':t.signal))+"]"}}const cO=t=>(e,n,r)=>PN(t,n,e||void 0,r),fO=cO("aggregate"),hO=cO("axisticks"),dO=cO("bound"),pO=cO("collect"),gO=cO("compare"),mO=cO("datajoin"),yO=cO("encode"),vO=cO("expression"),_O=cO("facet"),xO=cO("field"),bO=cO("key"),wO=cO("legendentries"),kO=cO("load"),AO=cO("mark"),MO=cO("multiextent"),EO=cO("multivalues"),DO=cO("overlap"),CO=cO("params"),FO=cO("prefacet"),SO=cO("projection"),$O=cO("proxy"),TO=cO("relay"),BO=cO("render"),zO=cO("scale"),NO=cO("sieve"),OO=cO("sortitems"),RO=cO("viewlayout"),UO=cO("values");let LO=0;const qO={min:"min",max:"max",count:"sum"};function PO(t,e){const n=e.getScale(t.name).params;let r;for(r in n.domain=HO(t.domain,t,e),null!=t.range&&(n.range=KO(t,e,n)),null!=t.interpolate&&function(t,e){e.interpolate=jO(t.type||t),null!=t.gamma&&(e.interpolateGamma=jO(t.gamma))}(t.interpolate,n),null!=t.nice&&(n.nice=function(t,e){return t.signal?e.signalRef(t.signal):A(t)?{interval:jO(t.interval),step:jO(t.step)}:jO(t)}(t.nice,e)),null!=t.bins&&(n.bins=function(t,e){return t.signal||k(t)?IO(t,e):e.objectProperty(t)}(t.bins,e)),t)lt(n,r)||"name"===r||(n[r]=jO(t[r],e))}function jO(t,e){return A(t)?t.signal?e.signalRef(t.signal):s("Unsupported object: "+Ct(t)):t}function IO(t,e){return t.signal?e.signalRef(t.signal):t.map((t=>jO(t,e)))}function WO(t){s("Can not find data set: "+Ct(t))}function HO(t,e,n){if(t)return t.signal?n.signalRef(t.signal):(k(t)?YO:t.fields?VO:GO)(t,e,n);null==e.domainMin&&null==e.domainMax||s("No scale domain defined for domainMin/domainMax to override.")}function YO(t,e,n){return t.map((t=>jO(t,n)))}function GO(t,e,n){const r=n.getData(t.data);return r||WO(t.data),cp(e.type)?r.valuesRef(n,t.field,JO(t.sort,!1)):pp(e.type)?r.domainRef(n,t.field):r.extentRef(n,t.field)}function VO(t,e,n){const r=t.data,i=t.fields.reduce(((t,e)=>(e=xt(e)?{data:r,field:e}:k(e)||e.signal?function(t,e){const n="_:vega:_"+LO++,r=pO({});if(k(t))r.value={$ingest:t};else if(t.signal){const i="setdata("+Ct(n)+","+t.signal+")";r.params.input=e.signalRef(i)}return e.addDataPipeline(n,[r,NO({})]),{data:n,field:"data"}}(e,n):e,t.push(e),t)),[]);return(cp(e.type)?XO:pp(e.type)?ZO:QO)(t,n,i)}function XO(t,e,n){const r=JO(t.sort,!0);let i,o;const a=n.map((t=>{const n=e.getData(t.data);return n||WO(t.data),n.countsRef(e,t.field,r)})),s={groupby:HN,pulse:a};r&&(i=r.op||"count",o=r.field?VN(i,r.field):"count",s.ops=[qO[i]],s.fields=[e.fieldRef(o)],s.as=[o]),i=e.add(fO(s));const u=e.add(pO({pulse:IN(i)}));return o=e.add(UO({field:HN,sort:e.sortRef(r),pulse:IN(u)})),IN(o)}function JO(t,e){return t&&(t.field||t.op?t.field||"count"===t.op?e&&t.field&&t.op&&!qO[t.op]&&s("Multiple domain scales can not be sorted using "+t.op):s("No field provided for sort aggregate op: "+t.op):A(t)?t.field="key":t={field:"key"}),t}function ZO(t,e,n){const r=n.map((t=>{const n=e.getData(t.data);return n||WO(t.data),n.domainRef(e,t.field)}));return IN(e.add(EO({values:r})))}function QO(t,e,n){const r=n.map((t=>{const n=e.getData(t.data);return n||WO(t.data),n.extentRef(e,t.field)}));return IN(e.add(MO({extents:r})))}function KO(t,e,n){const r=e.config.range;let i=t.range;if(i.signal)return e.signalRef(i.signal);if(xt(i)){if(r&<(r,i))return KO(t=ot({},t,{range:r[i]}),e,n);"width"===i?i=[0,{signal:"width"}]:"height"===i?i=cp(t.type)?[0,{signal:"height"}]:[{signal:"height"},0]:s("Unrecognized scale range value: "+Ct(i))}else{if(i.scheme)return n.scheme=k(i.scheme)?IO(i.scheme,e):jO(i.scheme,e),i.extent&&(n.schemeExtent=IO(i.extent,e)),void(i.count&&(n.schemeCount=jO(i.count,e)));if(i.step)return void(n.rangeStep=jO(i.step,e));if(cp(t.type)&&!k(i))return HO(i,t,e);k(i)||s("Unsupported range type: "+Ct(i))}return i.map((t=>(k(t)?IO:jO)(t,e)))}function tR(t,e,n){return k(t)?t.map((t=>tR(t,e,n))):A(t)?t.signal?n.signalRef(t.signal):"fit"===e?t:s("Unsupported parameter object: "+Ct(t)):t}const eR="top",nR="left",rR="right",iR="bottom",oR="center",aR="vertical",sR="start",uR="end",lR="index",cR="label",fR="offset",hR="perc",dR="perc2",pR="value",gR="guide-label",mR="guide-title",yR="group-title",vR="group-subtitle",_R="symbol",xR="gradient",bR="discrete",wR="size",kR=[wR,"shape","fill","stroke","strokeWidth","strokeDash","opacity"],AR={name:1,style:1,interactive:1},MR={value:0},ER={value:1},DR="group",CR="rect",FR="rule",SR="symbol",$R="text";function TR(t){return t.type=DR,t.interactive=t.interactive||!1,t}function BR(t,e){const n=(n,r)=>KN(t[n],KN(e[n],r));return n.isVertical=n=>aR===KN(t.direction,e.direction||(n?e.symbolDirection:e.gradientDirection)),n.gradientLength=()=>KN(t.gradientLength,e.gradientLength||e.gradientWidth),n.gradientThickness=()=>KN(t.gradientThickness,e.gradientThickness||e.gradientHeight),n.entryColumns=()=>KN(t.columns,KN(e.columns,+n.isVertical(!0))),n}function zR(t,e){const n=e&&(e.update&&e.update[t]||e.enter&&e.enter[t]);return n&&n.signal?n:n?n.value:null}function NR(t,e,n){return`item.anchor === '${sR}' ? ${t} : item.anchor === '${uR}' ? ${e} : ${n}`}const OR=NR(Ct(nR),Ct(rR),Ct(oR));function RR(t,e){return e?t?A(t)?Object.assign({},t,{offset:RR(t.offset,e)}):{value:t,offset:e}:e:t}function UR(t,e){return e?(t.name=e.name,t.style=e.style||t.style,t.interactive=!!e.interactive,t.encode=oN(t.encode,e,AR)):t.interactive=!1,t}function LR(t,e,n,r){const i=BR(t,n),o=i.isVertical(),a=i.gradientThickness(),s=i.gradientLength();let u,l,c,f,h;o?(l=[0,1],c=[0,0],f=a,h=s):(l=[0,0],c=[1,0],f=s,h=a);const d={enter:u={opacity:MR,x:MR,y:MR,width:nN(f),height:nN(h)},update:ot({},u,{opacity:ER,fill:{gradient:e,start:l,stop:c}}),exit:{opacity:MR}};return iN(d,{stroke:i("gradientStrokeColor"),strokeWidth:i("gradientStrokeWidth")},{opacity:i("gradientOpacity")}),UR({type:CR,role:_N,encode:d},r)}function qR(t,e,n,r,i){const o=BR(t,n),a=o.isVertical(),s=o.gradientThickness(),u=o.gradientLength();let l,c,f,h,d="";a?(l="y",f="y2",c="x",h="width",d="1-"):(l="x",f="x2",c="y",h="height");const p={opacity:MR,fill:{scale:e,field:pR}};p[l]={signal:d+"datum."+hR,mult:u},p[c]=MR,p[f]={signal:d+"datum."+dR,mult:u},p[h]=nN(s);const g={enter:p,update:ot({},p,{opacity:ER}),exit:{opacity:MR}};return iN(g,{stroke:o("gradientStrokeColor"),strokeWidth:o("gradientStrokeWidth")},{opacity:o("gradientOpacity")}),UR({type:CR,role:yN,key:pR,from:i,encode:g},r)}const PR=`datum.${hR}<=0?"${nR}":datum.${hR}>=1?"${rR}":"${oR}"`,jR=`datum.${hR}<=0?"${iR}":datum.${hR}>=1?"${eR}":"middle"`;function IR(t,e,n,r){const i=BR(t,e),o=i.isVertical(),a=nN(i.gradientThickness()),s=i.gradientLength();let u,l,c,f,h=i("labelOverlap"),d="";const p={enter:u={opacity:MR},update:l={opacity:ER,text:{field:cR}},exit:{opacity:MR}};return iN(p,{fill:i("labelColor"),fillOpacity:i("labelOpacity"),font:i("labelFont"),fontSize:i("labelFontSize"),fontStyle:i("labelFontStyle"),fontWeight:i("labelFontWeight"),limit:KN(t.labelLimit,e.gradientLabelLimit)}),o?(u.align={value:"left"},u.baseline=l.baseline={signal:jR},c="y",f="x",d="1-"):(u.align=l.align={signal:PR},u.baseline={value:"top"},c="x",f="y"),u[c]=l[c]={signal:d+"datum."+hR,mult:s},u[f]=l[f]=a,a.offset=KN(t.labelOffset,e.gradientLabelOffset)||0,h=h?{separation:i("labelSeparation"),method:h,order:"datum."+lR}:void 0,UR({type:$R,role:xN,style:gR,key:pR,from:r,encode:p,overlap:h},n)}function WR(t,e,n,r,i){const o=BR(t,e),a=n.entries,s=!(!a||!a.interactive),u=a?a.name:void 0,l=o("clipHeight"),c=o("symbolOffset"),f={data:"value"},h=`(${i}) ? datum.${fR} : datum.${wR}`,d=l?nN(l):{field:wR},p=`datum.${lR}`,g=`max(1, ${i})`;let m,y,v,_,x;d.mult=.5,m={enter:y={opacity:MR,x:{signal:h,mult:.5,offset:c},y:d},update:v={opacity:ER,x:y.x,y:y.y},exit:{opacity:MR}};let b=null,w=null;t.fill||(b=e.symbolBaseFillColor,w=e.symbolBaseStrokeColor),iN(m,{fill:o("symbolFillColor",b),shape:o("symbolType"),size:o("symbolSize"),stroke:o("symbolStrokeColor",w),strokeDash:o("symbolDash"),strokeDashOffset:o("symbolDashOffset"),strokeWidth:o("symbolStrokeWidth")},{opacity:o("symbolOpacity")}),kR.forEach((e=>{t[e]&&(v[e]=y[e]={scale:t[e],field:pR})}));const k=UR({type:SR,role:bN,key:pR,from:f,clip:!!l||void 0,encode:m},n.symbols),A=nN(c);A.offset=o("labelOffset"),m={enter:y={opacity:MR,x:{signal:h,offset:A},y:d},update:v={opacity:ER,text:{field:cR},x:y.x,y:y.y},exit:{opacity:MR}},iN(m,{align:o("labelAlign"),baseline:o("labelBaseline"),fill:o("labelColor"),fillOpacity:o("labelOpacity"),font:o("labelFont"),fontSize:o("labelFontSize"),fontStyle:o("labelFontStyle"),fontWeight:o("labelFontWeight"),limit:o("labelLimit")});const M=UR({type:$R,role:xN,style:gR,key:pR,from:f,encode:m},n.labels);return m={enter:{noBound:{value:!l},width:MR,height:l?nN(l):MR,opacity:MR},exit:{opacity:MR},update:v={opacity:ER,row:{signal:null},column:{signal:null}}},o.isVertical(!0)?(_=`ceil(item.mark.items.length / ${g})`,v.row.signal=`${p}%${_}`,v.column.signal=`floor(${p} / ${_})`,x={field:["row",p]}):(v.row.signal=`floor(${p} / ${g})`,v.column.signal=`${p} % ${g}`,x={field:p}),v.column.signal=`(${i})?${v.column.signal}:${p}`,TR({role:lN,from:r={facet:{data:r,name:"value",groupby:lR}},encode:oN(m,a,AR),marks:[k,M],name:u,interactive:s,sort:x})}const HR='item.orient === "left"',YR='item.orient === "right"',GR=`(${HR} || ${YR})`,VR=`datum.vgrad && ${GR}`,XR=NR('"top"','"bottom"','"middle"'),JR=`datum.vgrad && ${YR} ? (${NR('"right"','"left"','"center"')}) : (${GR} && !(datum.vgrad && ${HR})) ? "left" : ${OR}`,ZR=`item._anchor || (${GR} ? "middle" : "start")`,QR=`${VR} ? (${HR} ? -90 : 90) : 0`,KR=`${GR} ? (datum.vgrad ? (${YR} ? "bottom" : "top") : ${XR}) : "top"`;function tU(t,e){let n;return A(t)&&(t.signal?n=t.signal:t.path?n="pathShape("+eU(t.path)+")":t.sphere&&(n="geoShape("+eU(t.sphere)+', {type: "Sphere"})')),n?e.signalRef(n):!!t}function eU(t){return A(t)&&t.signal?t.signal:Ct(t)}function nU(t){const e=t.role||"";return e.startsWith("axis")||e.startsWith("legend")||e.startsWith("title")?e:t.type===DR?lN:e||sN}function rU(t){return{marktype:t.type,name:t.name||void 0,role:t.role||nU(t),zindex:+t.zindex||void 0,aria:t.aria,description:t.description}}function iU(t,e){return t&&t.signal?e.signalRef(t.signal):!1!==t}function oU(t,e){const n=Qa(t.type);n||s("Unrecognized transform type: "+Ct(t.type));const r=PN(n.type.toLowerCase(),null,aU(n,t,e));return t.signal&&e.addSignal(t.signal,e.proxy(r)),r.metadata=n.metadata||{},r}function aU(t,e,n){const r={},i=t.params.length;for(let o=0;olU(t,e,n)))):lU(t,r,n)}(t,e,n):"projection"===r?n.projectionRef(e[t.name]):t.array&&!ZN(i)?i.map((e=>uU(t,e,n))):uU(t,i,n):void(t.required&&s("Missing required "+Ct(e.type)+" parameter: "+Ct(t.name)))}function uU(t,e,n){const r=t.type;if(ZN(e))return dU(r)?s("Expression references can not be signals."):pU(r)?n.fieldRef(e):gU(r)?n.compareRef(e):n.signalRef(e.signal);{const i=t.expr||pU(r);return i&&cU(e)?n.exprRef(e.expr,e.as):i&&fU(e)?WN(e.field,e.as):dU(r)?DB(e,n):hU(r)?IN(n.getData(e).values):pU(r)?WN(e):gU(r)?n.compareRef(e):e}}function lU(t,e,n){const r=t.params.length;let i;for(let n=0;nt&&t.expr,fU=t=>t&&t.field,hU=t=>"data"===t,dU=t=>"expr"===t,pU=t=>"field"===t,gU=t=>"compare"===t;function mU(t,e){return t.$ref?t:t.data&&t.data.$ref?t.data:IN(e.getData(t.data).output)}function yU(t,e,n,r,i){this.scope=t,this.input=e,this.output=n,this.values=r,this.aggregate=i,this.index={}}function vU(t){return xt(t)?t:null}function _U(t,e,n){const r=VN(n.op,n.field);let i;if(e.ops){for(let t=0,n=e.as.length;tnull==t?"null":t)).join(",")+"),0)",e);u.update=l.$expr,u.params=l.$params}function wU(t,e){const n=nU(t),r=t.type===DR,i=t.from&&t.from.facet,o=t.overlap;let a,u,l,c,f,h,d,p=t.layout||n===lN||n===uN;const g=n===sN||p||i,m=function(t,e,n){let r,i,o,a,u;return t?(r=t.facet)&&(e||s("Only group marks can be faceted."),null!=r.field?a=u=mU(r,n):(t.data?u=IN(n.getData(t.data).aggregate):(o=oU(ot({type:"aggregate",groupby:V(r.groupby)},r.aggregate),n),o.params.key=n.keyRef(r.groupby),o.params.pulse=mU(r,n),a=u=IN(n.add(o))),i=n.keyRef(r.groupby,!0))):a=IN(n.add(pO(null,[{}]))),a||(a=mU(t,n)),{key:i,pulse:a,parent:u}}(t.from,r,e);u=e.add(mO({key:m.key||(t.key?WN(t.key):void 0),pulse:m.pulse,clean:!r}));const y=IN(u);u=l=e.add(pO({pulse:y})),u=e.add(AO({markdef:rU(t),interactive:iU(t.interactive,e),clip:tU(t.clip,e),context:{$context:!0},groups:e.lookup(),parent:e.signals.parent?e.signalRef("parent"):null,index:e.markpath(),pulse:IN(u)}));const v=IN(u);u=c=e.add(yO(BN(t.encode,t.type,n,t.style,e,{mod:!1,pulse:v}))),u.params.parent=e.encode(),t.transform&&t.transform.forEach((t=>{const n=oU(t,e),r=n.metadata;(r.generates||r.changes)&&s("Mark transforms should not generate new data."),r.nomod||(c.params.mod=!0),n.params.pulse=IN(u),e.add(u=n)})),t.sort&&(u=e.add(OO({sort:e.compareRef(t.sort),pulse:IN(u)})));const _=IN(u);(i||p)&&(p=e.add(RO({layout:e.objectProperty(t.layout),legends:e.legends,mark:v,pulse:_})),h=IN(p));const x=e.add(dO({mark:v,pulse:h||_}));d=IN(x),r&&(g&&(a=e.operators,a.pop(),p&&a.pop()),e.pushState(_,h||d,y),i?function(t,e,n){const r=t.from.facet,i=r.name,o=mU(r,e);let a;r.name||s("Facet must have a name: "+Ct(r)),r.data||s("Facet must reference a data set: "+Ct(r)),r.field?a=e.add(FO({field:e.fieldRef(r.field),pulse:o})):r.groupby?a=e.add(_O({key:e.keyRef(r.groupby),group:IN(e.proxy(n.parent)),pulse:o})):s("Facet must specify groupby or field: "+Ct(r));const u=e.fork(),l=u.add(pO()),c=u.add(NO({pulse:IN(l)}));u.addData(i,new yU(u,l,l,c)),u.addSignal("parent",null),a.params.subflow={$subflow:u.parse(t).toRuntime()}}(t,e,m):g?function(t,e,n){const r=e.add(FO({pulse:n.pulse})),i=e.fork();i.add(NO()),i.addSignal("parent",null),r.params.subflow={$subflow:i.parse(t).toRuntime()}}(t,e,m):e.parse(t),e.popState(),g&&(p&&a.push(p),a.push(x))),o&&(d=function(t,e,n){const r=t.method,i=t.bound,o=t.separation,a={separation:ZN(o)?n.signalRef(o.signal):o,method:ZN(r)?n.signalRef(r.signal):r,pulse:e};t.order&&(a.sort=n.compareRef({field:t.order}));if(i){const t=i.tolerance;a.boundTolerance=ZN(t)?n.signalRef(t.signal):+t,a.boundScale=n.scaleRef(i.scale),a.boundOrient=i.orient}return IN(n.add(DO(a)))}(o,d,e));const b=e.add(BO({pulse:d})),w=e.add(NO({pulse:IN(b)},void 0,e.parent()));null!=t.name&&(f=t.name,e.addData(f,new yU(e,l,b,w)),t.on&&t.on.forEach((t=>{(t.insert||t.remove||t.toggle)&&s("Marks only support modify triggers."),bU(t,e,f)})))}function kU(t,e){const n=e.config.legend,r=t.encode||{},i=BR(t,n),o=r.legend||{},a=o.name||void 0,u=o.interactive,l=o.style,c={};let f,h,d,p=0;kR.forEach((e=>t[e]?(c[e]=t[e],p=p||t[e]):0)),p||s("Missing valid scale for legend.");const g=function(t,e){let n=t.type||_R;t.type||1!==function(t){return kR.reduce(((e,n)=>e+(t[n]?1:0)),0)}(t)||!t.fill&&!t.stroke||(n=lp(e)?xR:fp(e)?bR:_R);return n!==xR?n:fp(e)?bR:xR}(t,e.scaleType(p)),m={title:null!=t.title,scales:c,type:g,vgrad:"symbol"!==g&&i.isVertical()},y=IN(e.add(pO(null,[m]))),v=IN(e.add(wO(h={type:g,scale:e.scaleRef(p),count:e.objectProperty(i("tickCount")),limit:e.property(i("symbolLimit")),values:e.objectProperty(t.values),minstep:e.property(t.tickMinStep),formatType:e.property(t.formatType),formatSpecifier:e.property(t.format)})));return g===xR?(d=[LR(t,p,n,r.gradient),IR(t,n,r.labels,v)],h.count=h.count||e.signalRef(`max(2,2*floor((${tO(i.gradientLength())})/100))`)):g===bR?d=[qR(t,p,n,r.gradient,v),IR(t,n,r.labels,v)]:(f=function(t,e){const n=BR(t,e);return{align:n("gridAlign"),columns:n.entryColumns(),center:{row:!0,column:!1},padding:{row:n("rowPadding"),column:n("columnPadding")}}}(t,n),d=[WR(t,n,r,v,tO(f.columns))],h.size=function(t,e,n){const r=tO(MU("size",t,n)),i=tO(MU("strokeWidth",t,n)),o=tO(function(t,e,n){return zR("fontSize",t)||function(t,e,n){const r=e.config.style[n];return r&&r[t]}("fontSize",e,n)}(n[1].encode,e,gR));return DB(`max(ceil(sqrt(${r})+${i}),${o})`,e)}(t,e,d[0].marks)),d=[TR({role:vN,from:y,encode:{enter:{x:{value:0},y:{value:0}}},marks:d,layout:f,interactive:u})],m.title&&d.push(function(t,e,n,r){const i=BR(t,e),o={enter:{opacity:MR},update:{opacity:ER,x:{field:{group:"padding"}},y:{field:{group:"padding"}}},exit:{opacity:MR}};return iN(o,{orient:i("titleOrient"),_anchor:i("titleAnchor"),anchor:{signal:ZR},angle:{signal:QR},align:{signal:JR},baseline:{signal:KR},text:t.title,fill:i("titleColor"),fillOpacity:i("titleOpacity"),font:i("titleFont"),fontSize:i("titleFontSize"),fontStyle:i("titleFontStyle"),fontWeight:i("titleFontWeight"),limit:i("titleLimit"),lineHeight:i("titleLineHeight")},{align:i("titleAlign"),baseline:i("titleBaseline")}),UR({type:$R,role:wN,style:mR,from:r,encode:o},n)}(t,n,r.title,y)),wU(TR({role:mN,from:y,encode:oN(AU(i,t,n),o,AR),marks:d,aria:i("aria"),description:i("description"),zindex:i("zindex"),name:a,interactive:u,style:l}),e)}function AU(t,e,n){const r={enter:{},update:{}};return iN(r,{orient:t("orient"),offset:t("offset"),padding:t("padding"),titlePadding:t("titlePadding"),cornerRadius:t("cornerRadius"),fill:t("fillColor"),stroke:t("strokeColor"),strokeWidth:n.strokeWidth,strokeDash:n.strokeDash,x:t("legendX"),y:t("legendY"),format:e.format,formatType:e.formatType}),r}function MU(t,e,n){return e[t]?`scale("${e[t]}",datum)`:zR(t,n[0].encode)}yU.fromEntries=function(t,e){const n=e.length,r=e[n-1],i=e[n-2];let o=e[0],a=null,s=1;for(o&&"load"===o.type&&(o=e[1]),t.add(e[0]);s{n.push(oU(t,e))})),t.on&&t.on.forEach((n=>{bU(n,e,t.name)})),e.addDataPipeline(t.name,function(t,e,n){const r=[];let i,o,a,s,u,l=null,c=!1,f=!1;t.values?ZN(t.values)||QN(t.format)?(r.push($U(e,t)),r.push(l=SU())):r.push(l=SU({$ingest:t.values,$format:t.format})):t.url?QN(t.url)||QN(t.format)?(r.push($U(e,t)),r.push(l=SU())):r.push(l=SU({$request:t.url,$format:t.format})):t.source&&(l=i=V(t.source).map((t=>IN(e.getData(t).output))),r.push(null));for(o=0,a=n.length;ot===iR||t===eR,BU=(t,e,n)=>ZN(t)?qU(t.signal,e,n):t===nR||t===eR?e:n,zU=(t,e,n)=>ZN(t)?UU(t.signal,e,n):TU(t)?e:n,NU=(t,e,n)=>ZN(t)?LU(t.signal,e,n):TU(t)?n:e,OU=(t,e,n)=>ZN(t)?PU(t.signal,e,n):t===eR?{value:e}:{value:n},RU=(t,e,n)=>ZN(t)?jU(t.signal,e,n):t===rR?{value:e}:{value:n},UU=(t,e,n)=>IU(`${t} === '${eR}' || ${t} === '${iR}'`,e,n),LU=(t,e,n)=>IU(`${t} !== '${eR}' && ${t} !== '${iR}'`,e,n),qU=(t,e,n)=>HU(`${t} === '${nR}' || ${t} === '${eR}'`,e,n),PU=(t,e,n)=>HU(`${t} === '${eR}'`,e,n),jU=(t,e,n)=>HU(`${t} === '${rR}'`,e,n),IU=(t,e,n)=>(e=null!=e?nN(e):e,n=null!=n?nN(n):n,WU(e)&&WU(n)?{signal:`${t} ? (${e=e?e.signal||Ct(e.value):null}) : (${n=n?n.signal||Ct(n.value):null})`}:[ot({test:t},e)].concat(n||[])),WU=t=>null==t||1===Object.keys(t).length,HU=(t,e,n)=>({signal:`${t} ? (${GU(e)}) : (${GU(n)})`}),YU=(t,e,n,r,i)=>({signal:(null!=r?`${t} === '${nR}' ? (${GU(r)}) : `:"")+(null!=n?`${t} === '${iR}' ? (${GU(n)}) : `:"")+(null!=i?`${t} === '${rR}' ? (${GU(i)}) : `:"")+(null!=e?`${t} === '${eR}' ? (${GU(e)}) : `:"")+"(null)"}),GU=t=>ZN(t)?t.signal:null==t?null:Ct(t),VU=(t,e)=>0===e?0:ZN(t)?{signal:`(${t.signal}) * ${e}`}:{value:t*e},XU=(t,e)=>{const n=t.signal;return n&&n.endsWith("(null)")?{signal:n.slice(0,-6)+e.signal}:t};function JU(t,e,n,r){let i;if(e&<(e,t))return e[t];if(lt(n,t))return n[t];if(t.startsWith("title")){switch(t){case"titleColor":i="fill";break;case"titleFont":case"titleFontSize":case"titleFontWeight":i=t[5].toLowerCase()+t.slice(6)}return r[mR][i]}if(t.startsWith("label")){switch(t){case"labelColor":i="fill";break;case"labelFont":case"labelFontSize":i=t[5].toLowerCase()+t.slice(6)}return r[gR][i]}return null}function ZU(t){const e={};for(const n of t)if(n)for(const t in n)e[t]=1;return Object.keys(e)}function QU(t,e){return{scale:t.scale,range:e}}function KU(t,e,n,r,i){const o=BR(t,e),a=t.orient,s=t.gridScale,u=BU(a,1,-1),l=function(t,e){if(1===e);else if(A(t)){let n=t=ot({},t);for(;null!=n.mult;){if(!A(n.mult))return n.mult=ZN(e)?{signal:`(${n.mult}) * (${e.signal})`}:n.mult*e,t;n=n.mult=ot({},n.mult)}n.mult=e}else t=ZN(e)?{signal:`(${e.signal}) * (${t||0})`}:e*(t||0);return t}(t.offset,u);let c,f,h;const d={enter:c={opacity:MR},update:h={opacity:ER},exit:f={opacity:MR}};iN(d,{stroke:o("gridColor"),strokeCap:o("gridCap"),strokeDash:o("gridDash"),strokeDashOffset:o("gridDashOffset"),strokeOpacity:o("gridOpacity"),strokeWidth:o("gridWidth")});const p={scale:t.scale,field:pR,band:i.band,extra:i.extra,offset:i.offset,round:o("tickRound")},g=zU(a,{signal:"height"},{signal:"width"}),m=s?{scale:s,range:0,mult:u,offset:l}:{value:0,offset:l},y=s?{scale:s,range:1,mult:u,offset:l}:ot(g,{mult:u,offset:l});return c.x=h.x=zU(a,p,m),c.y=h.y=NU(a,p,m),c.x2=h.x2=NU(a,y),c.y2=h.y2=zU(a,y),f.x=zU(a,p),f.y=NU(a,p),UR({type:FR,role:hN,key:pR,from:r,encode:d},n)}function tL(t,e,n,r,i){return{signal:'flush(range("'+t+'"), scale("'+t+'", datum.value), '+e+","+n+","+r+","+i+")"}}function eL(t,e,n,r){const i=BR(t,e),o=t.orient,a=BU(o,-1,1);let s,u;const l={enter:s={opacity:MR,anchor:nN(i("titleAnchor",null)),align:{signal:OR}},update:u=ot({},s,{opacity:ER,text:nN(t.title)}),exit:{opacity:MR}},c={signal:`lerp(range("${t.scale}"), ${NR(0,1,.5)})`};return u.x=zU(o,c),u.y=NU(o,c),s.angle=zU(o,MR,VU(a,90)),s.baseline=zU(o,OU(o,iR,eR),{value:iR}),u.angle=s.angle,u.baseline=s.baseline,iN(l,{fill:i("titleColor"),fillOpacity:i("titleOpacity"),font:i("titleFont"),fontSize:i("titleFontSize"),fontStyle:i("titleFontStyle"),fontWeight:i("titleFontWeight"),limit:i("titleLimit"),lineHeight:i("titleLineHeight")},{align:i("titleAlign"),angle:i("titleAngle"),baseline:i("titleBaseline")}),function(t,e,n,r){const i=(t,e)=>null!=t?(n.update[e]=XU(nN(t),n.update[e]),!1):!aN(e,r),o=i(t("titleX"),"x"),a=i(t("titleY"),"y");n.enter.auto=a===o?nN(a):zU(e,nN(a),nN(o))}(i,o,l,n),l.update.align=XU(l.update.align,s.align),l.update.angle=XU(l.update.angle,s.angle),l.update.baseline=XU(l.update.baseline,s.baseline),UR({type:$R,role:gN,style:mR,from:r,encode:l},n)}function nL(t,e){const n=function(t,e){var n,r,i,o=e.config,a=o.style,s=o.axis,u="band"===e.scaleType(t.scale)&&o.axisBand,l=t.orient;if(ZN(l)){const t=ZU([o.axisX,o.axisY]),e=ZU([o.axisTop,o.axisBottom,o.axisLeft,o.axisRight]);for(i of(n={},t))n[i]=zU(l,JU(i,o.axisX,s,a),JU(i,o.axisY,s,a));for(i of(r={},e))r[i]=YU(l.signal,JU(i,o.axisTop,s,a),JU(i,o.axisBottom,s,a),JU(i,o.axisLeft,s,a),JU(i,o.axisRight,s,a))}else n=l===eR||l===iR?o.axisX:o.axisY,r=o["axis"+l[0].toUpperCase()+l.slice(1)];return n||r||u?ot({},s,n,r,u):s}(t,e),r=t.encode||{},i=r.axis||{},o=i.name||void 0,a=i.interactive,s=i.style,u=BR(t,n),l=function(t){const e=t("tickBand");let n,r,i=t("tickOffset");return e?e.signal?(n={signal:`(${e.signal}) === 'extent' ? 1 : 0.5`},r={signal:`(${e.signal}) === 'extent'`},A(i)||(i={signal:`(${e.signal}) === 'extent' ? 0 : ${i}`})):"extent"===e?(n=1,r=!0,i=0):(n=.5,r=!1):(n=t("bandPosition"),r=t("tickExtra")),{extra:r,band:n,offset:i}}(u),c={scale:t.scale,ticks:!!u("ticks"),labels:!!u("labels"),grid:!!u("grid"),domain:!!u("domain"),title:null!=t.title},f=IN(e.add(pO({},[c]))),h=IN(e.add(hO({scale:e.scaleRef(t.scale),extra:e.property(l.extra),count:e.objectProperty(t.tickCount),values:e.objectProperty(t.values),minstep:e.property(t.tickMinStep),formatType:e.property(t.formatType),formatSpecifier:e.property(t.format)}))),d=[];let p;return c.grid&&d.push(KU(t,n,r.grid,h,l)),c.ticks&&(p=u("tickSize"),d.push(function(t,e,n,r,i,o){const a=BR(t,e),s=t.orient,u=BU(s,-1,1);let l,c,f;const h={enter:l={opacity:MR},update:f={opacity:ER},exit:c={opacity:MR}};iN(h,{stroke:a("tickColor"),strokeCap:a("tickCap"),strokeDash:a("tickDash"),strokeDashOffset:a("tickDashOffset"),strokeOpacity:a("tickOpacity"),strokeWidth:a("tickWidth")});const d=nN(i);d.mult=u;const p={scale:t.scale,field:pR,band:o.band,extra:o.extra,offset:o.offset,round:a("tickRound")};return f.y=l.y=zU(s,MR,p),f.y2=l.y2=zU(s,d),c.x=zU(s,p),f.x=l.x=NU(s,MR,p),f.x2=l.x2=NU(s,d),c.y=NU(s,p),UR({type:FR,role:pN,key:pR,from:r,encode:h},n)}(t,n,r.ticks,h,p,l))),c.labels&&(p=c.ticks?p:0,d.push(function(t,e,n,r,i,o){const a=BR(t,e),s=t.orient,u=t.scale,l=BU(s,-1,1),c=tO(a("labelFlush")),f=tO(a("labelFlushOffset")),h=a("labelAlign"),d=a("labelBaseline");let p,g=0===c||!!c;const m=nN(i);m.mult=l,m.offset=nN(a("labelPadding")||0),m.offset.mult=l;const y={scale:u,field:pR,band:.5,offset:RR(o.offset,a("labelOffset"))},v=zU(s,g?tL(u,c,'"left"','"right"','"center"'):{value:"center"},RU(s,"left","right")),_=zU(s,OU(s,"bottom","top"),g?tL(u,c,'"top"','"bottom"','"middle"'):{value:"middle"}),x=tL(u,c,`-(${f})`,f,0);g=g&&f;const b={opacity:MR,x:zU(s,y,m),y:NU(s,y,m)},w={enter:b,update:p={opacity:ER,text:{field:cR},x:b.x,y:b.y,align:v,baseline:_},exit:{opacity:MR,x:b.x,y:b.y}};iN(w,{dx:!h&&g?zU(s,x):null,dy:!d&&g?NU(s,x):null}),iN(w,{angle:a("labelAngle"),fill:a("labelColor"),fillOpacity:a("labelOpacity"),font:a("labelFont"),fontSize:a("labelFontSize"),fontWeight:a("labelFontWeight"),fontStyle:a("labelFontStyle"),limit:a("labelLimit"),lineHeight:a("labelLineHeight")},{align:h,baseline:d});const k=a("labelBound");let A=a("labelOverlap");return A=A||k?{separation:a("labelSeparation"),method:A,order:"datum.index",bound:k?{scale:u,orient:s,tolerance:k}:null}:void 0,p.align!==v&&(p.align=XU(p.align,v)),p.baseline!==_&&(p.baseline=XU(p.baseline,_)),UR({type:$R,role:dN,style:gR,key:pR,from:r,encode:w,overlap:A},n)}(t,n,r.labels,h,p,l))),c.domain&&d.push(function(t,e,n,r){const i=BR(t,e),o=t.orient;let a,s;const u={enter:a={opacity:MR},update:s={opacity:ER},exit:{opacity:MR}};iN(u,{stroke:i("domainColor"),strokeCap:i("domainCap"),strokeDash:i("domainDash"),strokeDashOffset:i("domainDashOffset"),strokeWidth:i("domainWidth"),strokeOpacity:i("domainOpacity")});const l=QU(t,0),c=QU(t,1);return a.x=s.x=zU(o,l,MR),a.x2=s.x2=zU(o,c),a.y=s.y=NU(o,l,MR),a.y2=s.y2=NU(o,c),UR({type:FR,role:fN,from:r,encode:u},n)}(t,n,r.domain,f)),c.title&&d.push(eL(t,n,r.title,f)),wU(TR({role:cN,from:f,encode:oN(rL(u,t),i,AR),marks:d,aria:u("aria"),description:u("description"),zindex:u("zindex"),name:o,interactive:a,style:s}),e)}function rL(t,e){const n={enter:{},update:{}};return iN(n,{orient:t("orient"),offset:t("offset")||0,position:KN(e.position,0),titlePadding:t("titlePadding"),minExtent:t("minExtent"),maxExtent:t("maxExtent"),range:{signal:`abs(span(range("${e.scale}")))`},translate:t("translate"),format:e.format,formatType:e.formatType}),n}function iL(t,e,n){const r=V(t.signals),i=V(t.scales);return n||r.forEach((t=>LN(t,e))),V(t.projections).forEach((t=>function(t,e){const n=e.config.projection||{},r={};for(const n in t)"name"!==n&&(r[n]=tR(t[n],n,e));for(const t in n)null==r[t]&&(r[t]=tR(n[t],t,e));e.addProjection(t.name,r)}(t,e))),i.forEach((t=>function(t,e){const n=t.type||"linear";sp(n)||s("Unrecognized scale type: "+Ct(n)),e.addScale(t.name,{type:n,domain:void 0})}(t,e))),V(t.data).forEach((t=>FU(t,e))),i.forEach((t=>PO(t,e))),(n||r).forEach((t=>function(t,e){const n=e.getSignal(t.name);let r=t.update;t.init&&(r?s("Signals can not include both init and update expressions."):(r=t.init,n.initonly=!0)),r&&(r=DB(r,e),n.update=r.$expr,n.params=r.$params),t.on&&t.on.forEach((t=>uO(t,e,n.id)))}(t,e))),V(t.axes).forEach((t=>nL(t,e))),V(t.marks).forEach((t=>wU(t,e))),V(t.legends).forEach((t=>kU(t,e))),t.title&&DU(t.title,e),e.parseLambdas(),e}const oL=t=>oN({enter:{x:{value:0},y:{value:0}},update:{width:{signal:"width"},height:{signal:"height"}}},t);function aL(t,e){const n=e.config,r=IN(e.root=e.add(jN())),i=function(t,e){const n=n=>KN(t[n],e[n]),r=[sL("background",n("background")),sL("autosize",Qz(n("autosize"))),sL("padding",eN(n("padding"))),sL("width",n("width")||0),sL("height",n("height")||0)],i=r.reduce(((t,e)=>(t[e.name]=e,t)),{}),o={};return V(t.signals).forEach((t=>{lt(i,t.name)?t=ot(i[t.name],t):r.push(t),o[t.name]=t})),V(e.signals).forEach((t=>{lt(o,t.name)||lt(i,t.name)||r.push(t)})),r}(t,n);i.forEach((t=>LN(t,e))),e.description=t.description||n.description,e.eventConfig=n.events,e.legends=e.objectProperty(n.legend&&n.legend.layout),e.locale=n.locale;const o=e.add(pO()),a=e.add(yO(BN(oL(t.encode),DR,uN,t.style,e,{pulse:IN(o)}))),s=e.add(RO({layout:e.objectProperty(t.layout),legends:e.legends,autosize:e.signalRef("autosize"),mark:r,pulse:IN(a)}));e.operators.pop(),e.pushState(IN(a),IN(s),null),iL(t,e,i),e.operators.push(s);let u=e.add(dO({mark:r,pulse:IN(s)}));return u=e.add(BO({pulse:IN(u)})),u=e.add(NO({pulse:IN(u)})),e.addData("root",new yU(e,o,o,u)),e}function sL(t,e){return e&&e.signal?{name:t,update:e.signal}:{name:t,value:e}}function uL(t,e){this.config=t||{},this.options=e||{},this.bindings=[],this.field={},this.signals={},this.lambdas={},this.scales={},this.events={},this.data={},this.streams=[],this.updates=[],this.operators=[],this.eventConfig=null,this.locale=null,this._id=0,this._subid=0,this._nextsub=[0],this._parent=[],this._encode=[],this._lookup=[],this._markpath=[]}function lL(t){this.config=t.config,this.options=t.options,this.legends=t.legends,this.field=Object.create(t.field),this.signals=Object.create(t.signals),this.lambdas=Object.create(t.lambdas),this.scales=Object.create(t.scales),this.events=Object.create(t.events),this.data=Object.create(t.data),this.streams=[],this.updates=[],this.operators=[],this._id=0,this._subid=++t._nextsub[0],this._nextsub=t._nextsub,this._parent=t._parent.slice(),this._encode=t._encode.slice(),this._lookup=t._lookup.slice(),this._markpath=t._markpath}function cL(t){return(k(t)?fL:hL)(t)}function fL(t){const e=t.length;let n="[";for(let r=0;r0?",":"")+(A(e)?e.signal||cL(e):Ct(e))}return n+"]"}function hL(t){let e,n,r="{",i=0;for(e in t)n=t[e],r+=(++i>1?",":"")+Ct(e)+":"+(A(n)?n.signal||cL(n):Ct(n));return r+"}"}uL.prototype=lL.prototype={parse(t){return iL(t,this)},fork(){return new lL(this)},isSubscope(){return this._subid>0},toRuntime(){return this.finish(),{description:this.description,operators:this.operators,streams:this.streams,updates:this.updates,bindings:this.bindings,eventConfig:this.eventConfig,locale:this.locale}},id(){return(this._subid?this._subid+":":0)+this._id++},add(t){return this.operators.push(t),t.id=this.id(),t.refs&&(t.refs.forEach((e=>{e.$ref=t.id})),t.refs=null),t},proxy(t){const e=t instanceof qN?IN(t):t;return this.add($O({value:e}))},addStream(t){return this.streams.push(t),t.id=this.id(),t},addUpdate(t){return this.updates.push(t),t},finish(){let t,e;for(t in this.root&&(this.root.root=!0),this.signals)this.signals[t].signal=t;for(t in this.scales)this.scales[t].scale=t;function n(t,e,n){let r,i;t&&(r=t.data||(t.data={}),i=r[e]||(r[e]=[]),i.push(n))}for(t in this.data){e=this.data[t],n(e.input,t,"input"),n(e.output,t,"output"),n(e.values,t,"values");for(const r in e.index)n(e.index[r],t,"index:"+r)}return this},pushState(t,e,n){this._encode.push(IN(this.add(NO({pulse:t})))),this._parent.push(e),this._lookup.push(n?IN(this.proxy(n)):null),this._markpath.push(-1)},popState(){this._encode.pop(),this._parent.pop(),this._lookup.pop(),this._markpath.pop()},parent(){return F(this._parent)},encode(){return F(this._encode)},lookup(){return F(this._lookup)},markpath(){const t=this._markpath;return++t[t.length-1]},fieldRef(t,e){if(xt(t))return WN(t,e);t.signal||s("Unsupported field reference: "+Ct(t));const n=t.signal;let r=this.field[n];if(!r){const t={name:this.signalRef(n)};e&&(t.as=e),this.field[n]=r=IN(this.add(xO(t)))}return r},compareRef(t){let e=!1;const n=t=>ZN(t)?(e=!0,this.signalRef(t.signal)):function(t){return t&&t.expr}(t)?(e=!0,this.exprRef(t.expr)):t,r=V(t.field).map(n),i=V(t.order).map(n);return e?IN(this.add(gO({fields:r,orders:i}))):YN(r,i)},keyRef(t,e){let n=!1;const r=this.signals;return t=V(t).map((t=>ZN(t)?(n=!0,IN(r[t.signal])):t)),n?IN(this.add(bO({fields:t,flat:e}))):function(t,e){const n={$key:t};return e&&(n.$flat=!0),n}(t,e)},sortRef(t){if(!t)return t;const e=VN(t.op,t.field),n=t.order||"ascending";return n.signal?IN(this.add(gO({fields:e,orders:this.signalRef(n.signal)}))):YN(e,n)},event(t,e){const n=t+":"+e;if(!this.events[n]){const r=this.id();this.streams.push({id:r,source:t,type:e}),this.events[n]=r}return this.events[n]},hasOwnSignal(t){return lt(this.signals,t)},addSignal(t,e){this.hasOwnSignal(t)&&s("Duplicate signal name: "+Ct(t));const n=e instanceof qN?e:this.add(jN(e));return this.signals[t]=n},getSignal(t){return this.signals[t]||s("Unrecognized signal name: "+Ct(t)),this.signals[t]},signalRef(t){return this.signals[t]?IN(this.signals[t]):(lt(this.lambdas,t)||(this.lambdas[t]=this.add(jN(null))),IN(this.lambdas[t]))},parseLambdas(){const t=Object.keys(this.lambdas);for(let e=0,n=t.length;er+Math.floor(o*t.random()),pdf:t=>t===Math.floor(t)&&t>=r&&t=i?1:(e-r+1)/o},icdf:t=>t>=0&&t<=1?r-1+Math.floor(t*o):NaN};return a.min(e).max(n)},t.randomKDE=gs,t.randomLCG=function(t){return function(){return(t=(1103515245*t+12345)%2147483647)/2147483647}},t.randomLogNormal=xs,t.randomMixture=bs,t.randomNormal=ps,t.randomUniform=Es,t.read=ca,t.regressionConstant=Ds,t.regressionExp=zs,t.regressionLinear=Ts,t.regressionLoess=Ls,t.regressionLog=Bs,t.regressionPoly=Rs,t.regressionPow=Ns,t.regressionQuad=Os,t.renderModule=T_,t.repeat=Mt,t.resetDefaultLocale=function(){return Co(),Bo(),Uo()},t.resetSVGClipId=Yg,t.resetSVGDefIds=function(){Yg(),Gp=0},t.responseType=la,t.runtimeContext=OB,t.sampleCurve=Is,t.sampleLogNormal=ms,t.sampleNormal=cs,t.sampleUniform=ws,t.scale=ap,t.sceneEqual=q_,t.sceneFromJSON=Zy,t.scenePickVisit=qm,t.sceneToJSON=Jy,t.sceneVisit=Lm,t.sceneZOrder=Um,t.scheme=Ap,t.serializeXML=r_,t.setHybridRendererOptions=function(t){A_.svgMarkTypes=t.svgMarkTypes??["text"],A_.svgOnTop=t.svgOnTop??!0,A_.debug=t.debug??!1},t.setRandom=function(e){t.random=e},t.span=Dt,t.splitAccessPath=u,t.stringValue=Ct,t.textMetrics=My,t.timeBin=Jr,t.timeFloor=wr,t.timeFormatLocale=No,t.timeInterval=Cr,t.timeOffset=$r,t.timeSequence=zr,t.timeUnitSpecifier=rr,t.timeUnits=er,t.toBoolean=Ft,t.toDate=$t,t.toNumber=S,t.toSet=Bt,t.toString=Tt,t.transform=Ka,t.transforms=Za,t.truncate=zt,t.truthy=p,t.tupleid=ya,t.typeParsers=Zo,t.utcFloor=Mr,t.utcInterval=Fr,t.utcOffset=Tr,t.utcSequence=Nr,t.utcdayofyear=hr,t.utcquarter=G,t.utcweek=dr,t.version="5.29.0",t.visitArray=Nt,t.week=sr,t.writeConfig=D,t.zero=h,t.zoomLinear=j,t.zoomLog=I,t.zoomPow=W,t.zoomSymlog=H})); +//# sourceMappingURL=vega.min.js.map diff --git a/docs/modules/custom_yara_rules.md b/docs/modules/custom_yara_rules.md new file mode 100644 index 000000000..1ec106878 --- /dev/null +++ b/docs/modules/custom_yara_rules.md @@ -0,0 +1,149 @@ +# Custom Yara Rules + +### Overview +Through the `excavate` internal module, BBOT supports searching through HTTP response data using custom YARA rules. + +This feature can be utilized with the command line option `--custom-yara-rules` or `-cy`, followed by a file containing the YARA rules. + +Example: + +``` +bbot -m httpx --custom-yara-rules=test.yara -t http://example.com/ +``` + +Where `test.yara` is a file on the filesystem. The file can contain multiple YARA rules, separated by lines. + +YARA rules can be quite simple, the simplest example being a single string search: + +``` +rule find_string { + strings: + $str1 = "AAAABBBB" + + condition: + $str1 +} +``` + +To look for multiple strings, and match if any of them were to hit: + +``` +rule find_string { + strings: + $str1 = "AAAABBBB" + $str2 = "CCCCDDDD" + + condition: + any of them +} +``` + +One of the most important capabilities is the use of regexes within the rule, as shown in the following example. + +``` +rule find_AAAABBBB_regex { + strings: + $regex = /A{1,4}B{1,4}/ + + condition: + $regex +} + +``` + +*Note: YARA uses it's own regex engine that is not a 1:1 match with python regexes. This means many existing regexes will have to be modified before they will work with YARA. The good news is: YARA's regex engine is FAST, immensely more fast than pythons!* + +Further discussion of art of writing complex YARA rules goes far beyond the scope of this documentation. A good place to start learning more is the [official YARA documentation](https://yara.readthedocs.io/en/stable/writingrules.html). + +The YARA engine provides plenty of room to make highly complex signatures possible, with various conditional operators available. Multiple signatures can be linked together to create sophisticated detection rules that can identify a wide range of specific content. This flexibility allows the crafting of efficient rules for detecting security vulnerabilities, leveraging logical operators, regular expressions, and other powerful features. Additionally, YARA's modular structure supports easy updates and maintenance of signature sets. + +### Custom options + +BBOT supports the use of a few custom `meta` attributes within YARA rules, which will alter the behavior of the rule and the post-processing of the results. + +#### description + +The description of the rule. Will end up in the description of any produced events if defined. + +Example with no description provided: + +``` +[FINDING] {"description": "Custom Yara Rule [find_string] Matched via identifier [str1]", "host": "example.com", "url": "http://example.com"} excavate +``` + +Example with the description added: + +``` +[FINDING] {"description": "Custom Yara Rule [AAAABBBB] with description: [contains our test string] Matched via identifier [str1]", "host": "example.com, "url": "http://example.com"} excavate +``` + +That FINDING was produced with the following signature: + +``` +rule AAAABBBB { + + meta: + description = "contains our test string" + strings: + $str1 = "AAAABBBB" + condition: + $str1 +} +``` + +#### tags + +Tags specified with this option will be passed-on to any resulting emitted events. Tags are provided as a comma separated string, as shown below: + +Lets expand on the previous example: + +``` +rule AAAABBBB { + + meta: + description = "contains our test string" + tags = "tag1,tag2,tag3" + strings: + $str1 = "AAAABBBB" + condition: + $str1 +} +``` + +Now, the BBOT FINDING includes these custom tags, as with the following output: + +``` +[FINDING] {"description": "Custom Yara Rule [AAAABBBB] with description: [contains our test string] Matched via identifier [str1]", "host": "example.com", "url": "http://example.com/"} excavate (tag1, tag2, tag3) +``` + +#### emit_match + +When set to True, the contents returned from a successful extraction via a YARA regex will be included in the FINDING event which is emitted. + +Consider the following example YARA rule: + +``` +rule SubstackLink +{ + meta: + description = "contains a Substack link" + emit_match = true + strings: + $substack_link = /https?:\/\/[a-zA-Z0-9.-]+\.substack\.com/ + condition: + $substack_link +} +``` + +When run against the Black Lantern Security homepage with the following BBOT command: + +``` +bbot -m httpx --custom-yara-rules=substack.yara -t http://www.blacklanternsecurity.com/ + +``` + +We get the following result. Note that the finding now contains the actual link that was identified with the regex. + +``` +[FINDING] {"description": "Custom Yara Rule [SubstackLink] with description: [contains a Substack link] Matched via identifier [substack_link] and extracted [https://blacklanternsecurity.substack.com]", "host": "www.blacklanternsecurity.com", "url": "https://www.blacklanternsecurity.com/"} excavate +``` diff --git a/docs/modules/internal_modules.md b/docs/modules/internal_modules.md new file mode 100644 index 000000000..1a22ebb1e --- /dev/null +++ b/docs/modules/internal_modules.md @@ -0,0 +1,85 @@ +# List of Modules + +## What are internal modules? + +Internal modules are just like regular modules, except that they run all the time. They do not have to be explicitly enabled. They can, however, be explicitly disabled if needed. + +Turning them off is simple, a root-level config option is present which can be set to False to disable them: + +``` +# Infer certain events from others, e.g. IPs from IP ranges, DNS_NAMEs from URLs, etc. +speculate: True +# Passively search event data for URLs, hostnames, emails, etc. +excavate: True +# Summarize activity at the end of a scan +aggregate: True +# DNS resolution +dnsresolve: True +# Cloud provider tagging +cloudcheck: True +``` + +These modules are executing core functionality that is normally essential for a typical BBOT scan. Let's take a quick look at each one's functionality: + +### aggregate + +Summarize statistics at the end of a scan. Disable if you don't want to see this table. + +### cloud + +The cloud module looks at events and tries to determine if they are associated with a cloud provider and tags them as such, and can also identify certain cloud resources + +### dns + +The DNS internal module controls the basic DNS resoultion the BBOT performs, and all of the supporting machinery like wildcard detection, etc. + +### excavate + +The excavate internal module designed to passively extract valuable information from HTTP response data. It primarily uses YARA regexes to extract information, with various events being produced from the post-processing of the YARA results. + +Here is a summary of the data it produces: + +#### URLs + +By extracting URLs from all visited pages, this is actually already half of a web-spider. The other half is recursion, which is baked in to BBOT from the ground up. Therefore, protections are in place by default in the form of `web_spider_distance` and `web_spider_depth` settings. These settings govern restrictions to URLs recursively harvested from HTTP responses, preventing endless runaway scans. However, in the right situation the controlled use of a web-spider is extremely powerful. + +#### Parameter Extraction + +Parameter Extraction +The parameter extraction functionality identifies and extracts key web parameters from HTTP responses, and produced `WEB_PARAMETER` events. This includes parameters found in GET and POST requests, HTML forms, and jQuery requests. Currently, these are only used by the `hunt` module, and by the `paramminer` modules, to a limited degree. However, future functionality will make extensive use of these events. + +#### Email Extraction + +Detect email addresses within HTTP_RESPONSE data. + +#### Error Detection + +Scans for verbose error messages in HTTP responses and raw text data. By identifying specific error signatures from various programming languages and frameworks, this feature helps uncover misconfigurations, debugging information, and potential vulnerabilities. This insight is invaluable for identifying weak points or anomalies in web applications. + +#### Content Security Policy (CSP) Extraction +The CSP extraction capability focuses on extracting domains from Content-Security-Policy headers. By analyzing these headers, BBOT can identify additional domains which can get fed back into the scan. + +#### Serialization Detection +Serialized objects are a common source of serious security vulnerablities. Excavate aims to detect those used in Java, .NET, and PHP applications. + +#### Functionality Detection +Looks for specific web functionalities such as file upload fields and WSDL URLs. By identifying these elements, BBOT can pinpoint areas of the application that may require further scrutiny for security vulnerabilities. + +#### Non-HTTP Scheme Detection +The non-HTTP scheme detection capability extracts URLs with non-HTTP schemes, such as ftp, mailto, and javascript. By identifying these URLs, BBOT can uncover additional vectors for attack or information leakage. + +#### Custom Yara Rules + +Excavate supports the use of custom YARA rules, which wil be added to the other rules before the scan start. For more info, view this. + +### speculate + +Speculate is all about inferring one data type from another, particularly when certain tools like port scanners are not enabled. This is essential functionality for most BBOT scans, allowing for the discovery of web resources when starting with a DNS-only target list without a port scanner. It bridges gaps in the data, providing a more comprehensive view of the target by leveraging existing information. + +* IP_RANGE: Converts an IP range into individual IP addresses and emits them as IP_ADDRESS events. +* DNS_NAME: Generates parent domains from DNS names. +* URL and URL_UNVERIFIED: Infers open TCP ports from URLs and speculates on sub-directory URLs. +* General URL Speculation: Emits URL_UNVERIFIED events for URLs not already in the event's history. +* IP_ADDRESS / DNS_NAME: Infers open TCP ports if active port scanning is not enabled. +* ORG_STUB: Derives organization stubs from TLDs, social stubs, or Azure tenant names and emits them as ORG_STUB events. +* USERNAME: Converts usernames to email addresses if they validate as such. \ No newline at end of file diff --git a/docs/modules/list_of_modules.md b/docs/modules/list_of_modules.md index b5989a1aa..74ef28b2a 100644 --- a/docs/modules/list_of_modules.md +++ b/docs/modules/list_of_modules.md @@ -1,130 +1,133 @@ # List of Modules -| Module | Type | Needs API Key | Description | Flags | Consumed Events | Produced Events | -|----------------------|----------|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------| -| ajaxpro | scan | No | Check for potentially vulnerable Ajaxpro instances | active, safe, web-thorough | HTTP_RESPONSE, URL | FINDING, VULNERABILITY | -| baddns | scan | No | Check hosts for domain/subdomain takeovers | active, baddns, cloud-enum, safe, subdomain-hijack, web-basic | DNS_NAME, DNS_NAME_UNRESOLVED | FINDING, VULNERABILITY | -| baddns_zone | scan | No | Check hosts for DNS zone transfers and NSEC walks | active, baddns, cloud-enum, safe, subdomain-enum | DNS_NAME | FINDING, VULNERABILITY | -| badsecrets | scan | No | Library for detecting known or weak secrets across many web frameworks | active, safe, web-basic, web-thorough | HTTP_RESPONSE | FINDING, TECHNOLOGY, VULNERABILITY | -| bucket_amazon | scan | No | Check for S3 buckets related to target | active, cloud-enum, safe, web-basic, web-thorough | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | -| bucket_azure | scan | No | Check for Azure storage blobs related to target | active, cloud-enum, safe, web-basic, web-thorough | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | -| bucket_digitalocean | scan | No | Check for DigitalOcean spaces related to target | active, cloud-enum, safe, slow, web-thorough | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | -| bucket_firebase | scan | No | Check for open Firebase databases related to target | active, cloud-enum, safe, web-basic, web-thorough | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | -| bucket_google | scan | No | Check for Google object storage related to target | active, cloud-enum, safe, web-basic, web-thorough | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | -| bypass403 | scan | No | Check 403 pages for common bypasses | active, aggressive, web-thorough | URL | FINDING | -| dastardly | scan | No | Lightweight web application security scanner | active, aggressive, deadly, slow, web-thorough | HTTP_RESPONSE | FINDING, VULNERABILITY | -| dotnetnuke | scan | No | Scan for critical DotNetNuke (DNN) vulnerabilities | active, aggressive, web-thorough | HTTP_RESPONSE | TECHNOLOGY, VULNERABILITY | -| ffuf | scan | No | A fast web fuzzer written in Go | active, aggressive, deadly | URL | URL_UNVERIFIED | -| ffuf_shortnames | scan | No | Use ffuf in combination IIS shortnames | active, aggressive, iis-shortnames, web-thorough | URL_HINT | URL_UNVERIFIED | -| filedownload | scan | No | Download common filetypes such as PDF, DOCX, PPTX, etc. | active, safe, web-basic, web-thorough | HTTP_RESPONSE, URL_UNVERIFIED | FILESYSTEM | -| fingerprintx | scan | No | Fingerprint exposed services like RDP, SSH, MySQL, etc. | active, safe, service-enum, slow | OPEN_TCP_PORT | PROTOCOL | -| generic_ssrf | scan | No | Check for generic SSRFs | active, aggressive, web-thorough | URL | VULNERABILITY | -| git | scan | No | Check for exposed .git repositories | active, safe, web-basic, web-thorough | URL | FINDING | -| gitlab | scan | No | Detect GitLab instances and query them for repositories | active, safe | HTTP_RESPONSE, SOCIAL, TECHNOLOGY | CODE_REPOSITORY, FINDING, SOCIAL, TECHNOLOGY | -| gowitness | scan | No | Take screenshots of webpages | active, safe, web-screenshots | SOCIAL, URL | TECHNOLOGY, URL, URL_UNVERIFIED, WEBSCREENSHOT | -| host_header | scan | No | Try common HTTP Host header spoofing techniques | active, aggressive, web-thorough | HTTP_RESPONSE | FINDING | -| httpx | scan | No | Visit webpages. Many other modules rely on httpx | active, cloud-enum, safe, social-enum, subdomain-enum, web-basic, web-thorough | OPEN_TCP_PORT, URL, URL_UNVERIFIED | HTTP_RESPONSE, URL | -| hunt | scan | No | Watch for commonly-exploitable HTTP parameters | active, safe, web-thorough | HTTP_RESPONSE | FINDING | -| iis_shortnames | scan | No | Check for IIS shortname vulnerability | active, iis-shortnames, safe, web-basic, web-thorough | URL | URL_HINT | -| masscan | scan | No | Port scan with masscan. By default, scans top 100 ports. | active, aggressive, portscan | IP_ADDRESS, IP_RANGE | OPEN_TCP_PORT | -| newsletters | scan | No | Searches for Newsletter Submission Entry Fields on Websites | active, safe | HTTP_RESPONSE | FINDING | -| nmap | scan | No | Port scan with nmap. By default, scans top 100 ports. | active, aggressive, portscan, web-thorough | DNS_NAME, IP_ADDRESS, IP_RANGE | OPEN_TCP_PORT | -| ntlm | scan | No | Watch for HTTP endpoints that support NTLM authentication | active, safe, web-basic, web-thorough | HTTP_RESPONSE, URL | DNS_NAME, FINDING | -| nuclei | scan | No | Fast and customisable vulnerability scanner | active, aggressive, deadly | URL | FINDING, TECHNOLOGY, VULNERABILITY | -| oauth | scan | No | Enumerate OAUTH and OpenID Connect services | active, affiliates, cloud-enum, safe, subdomain-enum, web-basic, web-thorough | DNS_NAME, URL_UNVERIFIED | DNS_NAME | -| paramminer_cookies | scan | No | Smart brute-force to check for common HTTP cookie parameters | active, aggressive, slow, web-paramminer | HTTP_RESPONSE | FINDING | -| paramminer_getparams | scan | No | Use smart brute-force to check for common HTTP GET parameters | active, aggressive, slow, web-paramminer | HTTP_RESPONSE | FINDING | -| paramminer_headers | scan | No | Use smart brute-force to check for common HTTP header parameters | active, aggressive, slow, web-paramminer | HTTP_RESPONSE | FINDING | -| robots | scan | No | Look for and parse robots.txt | active, safe, web-basic, web-thorough | URL | URL_UNVERIFIED | -| secretsdb | scan | No | Detect common secrets with secrets-patterns-db | active, safe, web-basic, web-thorough | HTTP_RESPONSE | FINDING | -| smuggler | scan | No | Check for HTTP smuggling | active, aggressive, slow, web-thorough | URL | FINDING | -| sslcert | scan | No | Visit open ports and retrieve SSL certificates | active, affiliates, email-enum, safe, subdomain-enum, web-basic, web-thorough | OPEN_TCP_PORT | DNS_NAME, EMAIL_ADDRESS | -| telerik | scan | No | Scan for critical Telerik vulnerabilities | active, aggressive, web-thorough | HTTP_RESPONSE, URL | FINDING, VULNERABILITY | -| url_manipulation | scan | No | Attempt to identify URL parsing/routing based vulnerabilities | active, aggressive, web-thorough | URL | FINDING | -| vhost | scan | No | Fuzz for virtual hosts | active, aggressive, deadly, slow | URL | DNS_NAME, VHOST | -| wafw00f | scan | No | Web Application Firewall Fingerprinting Tool | active, aggressive | URL | WAF | -| wappalyzer | scan | No | Extract technologies from web responses | active, safe, web-basic, web-thorough | HTTP_RESPONSE | TECHNOLOGY | -| wpscan | scan | No | Wordpress security scanner. Highly recommended to use an API key for better results. | active, aggressive | HTTP_RESPONSE, TECHNOLOGY | FINDING, TECHNOLOGY, URL_UNVERIFIED, VULNERABILITY | -| affiliates | scan | No | Summarize affiliate domains at the end of a scan | affiliates, passive, report, safe | * | | -| anubisdb | scan | No | Query jldc.me's database for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| asn | scan | No | Query ripe and bgpview.io for ASNs | passive, report, safe, subdomain-enum | IP_ADDRESS | ASN | -| azure_realm | scan | No | Retrieves the "AuthURL" from login.microsoftonline.com/getuserrealm | affiliates, cloud-enum, passive, safe, subdomain-enum, web-basic, web-thorough | DNS_NAME | URL_UNVERIFIED | -| azure_tenant | scan | No | Query Azure for tenant sister domains | affiliates, cloud-enum, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| bevigil | scan | Yes | Retrieve OSINT data from mobile applications using BeVigil | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, URL_UNVERIFIED | -| binaryedge | scan | Yes | Query the BinaryEdge API | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| bucket_file_enum | scan | No | Works in conjunction with the filedownload module to download files from open storage buckets. Currently supported cloud providers: AWS, DigitalOcean | cloud-enum, passive, safe | STORAGE_BUCKET | URL_UNVERIFIED | -| builtwith | scan | Yes | Query Builtwith.com for subdomains | affiliates, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| c99 | scan | Yes | Query the C99 API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| censys | scan | Yes | Query the Censys API | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| certspotter | scan | No | Query Certspotter's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| chaos | scan | Yes | Query ProjectDiscovery's Chaos API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| code_repository | scan | No | Look for code repository links in webpages | passive, repo-enum, safe | URL_UNVERIFIED | CODE_REPOSITORY | -| columbus | scan | No | Query the Columbus Project API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| credshed | scan | Yes | Send queries to your own credshed server to check for known credentials of your targets | passive, safe | DNS_NAME | EMAIL_ADDRESS, HASHED_PASSWORD, PASSWORD, USERNAME | -| crobat | scan | No | Query Project Crobat for subdomains | passive, safe | DNS_NAME | DNS_NAME | -| crt | scan | No | Query crt.sh (certificate transparency) for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| dehashed | scan | Yes | Execute queries against dehashed.com for exposed credentials | email-enum, passive, safe | DNS_NAME | HASHED_PASSWORD, PASSWORD, USERNAME | -| digitorus | scan | No | Query certificatedetails.com for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| dnscaa | scan | No | Check for CAA records | email-enum, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, EMAIL_ADDRESS, URL_UNVERIFIED | -| dnscommonsrv | scan | No | Check for common SRV records | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| dnsdumpster | scan | No | Query dnsdumpster for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| docker_pull | scan | No | Download images from a docker repository | passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | -| dockerhub | scan | No | Search for docker repositories of discovered orgs/usernames | passive, safe | ORG_STUB, SOCIAL | CODE_REPOSITORY, SOCIAL, URL_UNVERIFIED | -| emailformat | scan | No | Query email-format.com for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | -| fullhunt | scan | Yes | Query the fullhunt.io API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| git_clone | scan | No | Clone code github repositories | passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | -| github_codesearch | scan | Yes | Query Github's API for code containing the target domain name | passive, safe, subdomain-enum | DNS_NAME | CODE_REPOSITORY, URL_UNVERIFIED | -| github_org | scan | No | Query Github's API for organization and member repositories | passive, safe, subdomain-enum | ORG_STUB, SOCIAL | CODE_REPOSITORY | -| github_workflows | scan | No | Download a github repositories workflow logs | passive, safe | CODE_REPOSITORY | FILESYSTEM | -| hackertarget | scan | No | Query the hackertarget.com API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| hunterio | scan | Yes | Query hunter.io for emails | email-enum, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, EMAIL_ADDRESS, URL_UNVERIFIED | -| internetdb | scan | No | Query Shodan's InternetDB for open ports, hostnames, technologies, and vulnerabilities | passive, portscan, safe, subdomain-enum | DNS_NAME, IP_ADDRESS | DNS_NAME, FINDING, OPEN_TCP_PORT, TECHNOLOGY, VULNERABILITY | -| ip2location | scan | Yes | Query IP2location.io's API for geolocation information. | passive, safe | IP_ADDRESS | GEOLOCATION | -| ipneighbor | scan | No | Look beside IPs in their surrounding subnet | aggressive, passive, subdomain-enum | IP_ADDRESS | IP_ADDRESS | -| ipstack | scan | Yes | Query IPStack's GeoIP API | passive, safe | IP_ADDRESS | GEOLOCATION | -| leakix | scan | No | Query leakix.net for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| massdns | scan | No | Brute-force subdomains with massdns (highly effective) | aggressive, passive, subdomain-enum | DNS_NAME | DNS_NAME | -| myssl | scan | No | Query myssl.com's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| otx | scan | No | Query otx.alienvault.com for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| passivetotal | scan | Yes | Query the PassiveTotal API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| pgp | scan | No | Query common PGP servers for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | -| postman | scan | No | Query Postman's API for related workspaces, collections, requests | passive, safe, subdomain-enum | DNS_NAME | URL_UNVERIFIED | -| rapiddns | scan | No | Query rapiddns.io for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| riddler | scan | No | Query riddler.io for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| securitytrails | scan | Yes | Query the SecurityTrails API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| shodan_dns | scan | Yes | Query Shodan for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| sitedossier | scan | No | Query sitedossier.com for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| skymem | scan | No | Query skymem.info for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | -| social | scan | No | Look for social media links in webpages | passive, safe, social-enum | URL_UNVERIFIED | SOCIAL | -| subdomaincenter | scan | No | Query subdomain.center's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| sublist3r | scan | No | Query sublist3r's API for subdomains | passive, safe | DNS_NAME | DNS_NAME | -| threatminer | scan | No | Query threatminer's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| trufflehog | scan | No | TruffleHog is a tool for finding credentials | passive, safe | FILESYSTEM | FINDING, VULNERABILITY | -| unstructured | scan | No | Module to extract data from files | passive, safe | FILESYSTEM | FILESYSTEM, RAW_TEXT | -| urlscan | scan | No | Query urlscan.io for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, URL_UNVERIFIED | -| viewdns | scan | No | Query viewdns.info's reverse whois for related domains | affiliates, passive, safe | DNS_NAME | DNS_NAME | -| virustotal | scan | Yes | Query VirusTotal's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| wayback | scan | No | Query archive.org's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, URL_UNVERIFIED | -| zoomeye | scan | Yes | Query ZoomEye's API for subdomains | affiliates, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | -| asset_inventory | output | No | Merge hosts, open ports, technologies, findings, etc. into a single asset inventory CSV | | DNS_NAME, FINDING, HTTP_RESPONSE, IP_ADDRESS, OPEN_TCP_PORT, TECHNOLOGY, URL, VULNERABILITY, WAF | IP_ADDRESS, OPEN_TCP_PORT | -| csv | output | No | Output to CSV | | * | | -| discord | output | No | Message a Discord channel when certain events are encountered | | * | | -| emails | output | No | Output any email addresses found belonging to the target domain | email-enum | EMAIL_ADDRESS | | -| http | output | No | Send every event to a custom URL via a web request | | * | | -| human | output | No | Output to text | | * | | -| json | output | No | Output to Newline-Delimited JSON (NDJSON) | | * | | -| neo4j | output | No | Output to Neo4j | | * | | -| python | output | No | Output via Python API | | * | | -| slack | output | No | Message a Slack channel when certain events are encountered | | * | | -| splunk | output | No | Send every event to a splunk instance through HTTP Event Collector | | * | | -| subdomains | output | No | Output only resolved, in-scope subdomains | subdomain-enum | DNS_NAME, DNS_NAME_UNRESOLVED | | -| teams | output | No | Message a Teams channel when certain events are encountered | | * | | -| web_report | output | No | Create a markdown report with web assets | | FINDING, TECHNOLOGY, URL, VHOST, VULNERABILITY | | -| websocket | output | No | Output to websockets | | * | | -| aggregate | internal | No | Summarize statistics at the end of a scan | passive, safe | | | -| excavate | internal | No | Passively extract juicy tidbits from scan data | passive | HTTP_RESPONSE, RAW_TEXT | URL_UNVERIFIED | -| speculate | internal | No | Derive certain event types from others by common sense | passive | AZURE_TENANT, DNS_NAME, DNS_NAME_UNRESOLVED, HTTP_RESPONSE, IP_ADDRESS, IP_RANGE, SOCIAL, STORAGE_BUCKET, URL, URL_UNVERIFIED, USERNAME | DNS_NAME, FINDING, IP_ADDRESS, OPEN_TCP_PORT, ORG_STUB | +| Module | Type | Needs API Key | Description | Flags | Consumed Events | Produced Events | Author | Created Date | +|----------------------|----------|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------|------------------|----------------| +| ajaxpro | scan | No | Check for potentially vulnerable Ajaxpro instances | active, safe, web-thorough | HTTP_RESPONSE, URL | FINDING, VULNERABILITY | @liquidsec | 2024-01-18 | +| baddns | scan | No | Check hosts for domain/subdomain takeovers | active, baddns, cloud-enum, safe, subdomain-hijack, web-basic | DNS_NAME, DNS_NAME_UNRESOLVED | FINDING, VULNERABILITY | @liquidsec | 2024-01-18 | +| baddns_zone | scan | No | Check hosts for DNS zone transfers and NSEC walks | active, baddns, cloud-enum, safe, subdomain-enum | DNS_NAME | FINDING, VULNERABILITY | @liquidsec | 2024-01-29 | +| badsecrets | scan | No | Library for detecting known or weak secrets across many web frameworks | active, safe, web-basic | HTTP_RESPONSE | FINDING, TECHNOLOGY, VULNERABILITY | @liquidsec | 2022-11-19 | +| bucket_amazon | scan | No | Check for S3 buckets related to target | active, cloud-enum, safe, web-basic | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | @TheTechromancer | 2022-11-04 | +| bucket_azure | scan | No | Check for Azure storage blobs related to target | active, cloud-enum, safe, web-basic | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | @TheTechromancer | 2022-11-04 | +| bucket_digitalocean | scan | No | Check for DigitalOcean spaces related to target | active, cloud-enum, safe, slow, web-thorough | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | @TheTechromancer | 2022-11-08 | +| bucket_firebase | scan | No | Check for open Firebase databases related to target | active, cloud-enum, safe, web-basic | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | @TheTechromancer | 2023-03-20 | +| bucket_google | scan | No | Check for Google object storage related to target | active, cloud-enum, safe, web-basic | DNS_NAME, STORAGE_BUCKET | FINDING, STORAGE_BUCKET | @TheTechromancer | 2022-11-04 | +| bypass403 | scan | No | Check 403 pages for common bypasses | active, aggressive, web-thorough | URL | FINDING | @liquidsec | 2022-07-05 | +| dastardly | scan | No | Lightweight web application security scanner | active, aggressive, deadly, slow, web-thorough | HTTP_RESPONSE | FINDING, VULNERABILITY | @domwhewell-sage | 2023-12-11 | +| dotnetnuke | scan | No | Scan for critical DotNetNuke (DNN) vulnerabilities | active, aggressive, web-thorough | HTTP_RESPONSE | TECHNOLOGY, VULNERABILITY | @liquidsec | 2023-11-21 | +| ffuf | scan | No | A fast web fuzzer written in Go | active, aggressive, deadly | URL | URL_UNVERIFIED | @pmueller | 2022-04-10 | +| ffuf_shortnames | scan | No | Use ffuf in combination IIS shortnames | active, aggressive, iis-shortnames, web-thorough | URL_HINT | URL_UNVERIFIED | @liquidsec | 2022-07-05 | +| filedownload | scan | No | Download common filetypes such as PDF, DOCX, PPTX, etc. | active, safe, web-basic | HTTP_RESPONSE, URL_UNVERIFIED | FILESYSTEM | @TheTechromancer | 2023-10-11 | +| fingerprintx | scan | No | Fingerprint exposed services like RDP, SSH, MySQL, etc. | active, safe, service-enum, slow | OPEN_TCP_PORT | PROTOCOL | @TheTechromancer | 2023-01-30 | +| generic_ssrf | scan | No | Check for generic SSRFs | active, aggressive, web-thorough | URL | VULNERABILITY | @liquidsec | 2022-07-30 | +| git | scan | No | Check for exposed .git repositories | active, code-enum, safe, web-basic | URL | FINDING | @TheTechromancer | 2023-05-30 | +| gitlab | scan | No | Detect GitLab instances and query them for repositories | active, code-enum, safe | HTTP_RESPONSE, SOCIAL, TECHNOLOGY | CODE_REPOSITORY, FINDING, SOCIAL, TECHNOLOGY | @TheTechromancer | 2024-03-11 | +| gowitness | scan | No | Take screenshots of webpages | active, safe, web-screenshots | SOCIAL, URL | TECHNOLOGY, URL, URL_UNVERIFIED, WEBSCREENSHOT | @TheTechromancer | 2022-07-08 | +| host_header | scan | No | Try common HTTP Host header spoofing techniques | active, aggressive, web-thorough | HTTP_RESPONSE | FINDING | @liquidsec | 2022-07-27 | +| httpx | scan | No | Visit webpages. Many other modules rely on httpx | active, cloud-enum, safe, social-enum, subdomain-enum, web-basic | OPEN_TCP_PORT, URL, URL_UNVERIFIED | HTTP_RESPONSE, URL | @TheTechromancer | 2022-07-08 | +| hunt | scan | No | Watch for commonly-exploitable HTTP parameters | active, safe, web-thorough | WEB_PARAMETER | FINDING | @liquidsec | 2022-07-20 | +| iis_shortnames | scan | No | Check for IIS shortname vulnerability | active, iis-shortnames, safe, web-basic | URL | URL_HINT | @pmueller | 2022-04-15 | +| newsletters | scan | No | Searches for Newsletter Submission Entry Fields on Websites | active, safe | HTTP_RESPONSE | FINDING | @stryker2k2 | 2024-02-02 | +| ntlm | scan | No | Watch for HTTP endpoints that support NTLM authentication | active, safe, web-basic | HTTP_RESPONSE, URL | DNS_NAME, FINDING | @liquidsec | 2022-07-25 | +| nuclei | scan | No | Fast and customisable vulnerability scanner | active, aggressive, deadly | URL | FINDING, TECHNOLOGY, VULNERABILITY | @TheTechromancer | 2022-03-12 | +| oauth | scan | No | Enumerate OAUTH and OpenID Connect services | active, affiliates, cloud-enum, safe, subdomain-enum, web-basic | DNS_NAME, URL_UNVERIFIED | DNS_NAME | @TheTechromancer | 2023-07-12 | +| paramminer_cookies | scan | No | Smart brute-force to check for common HTTP cookie parameters | active, aggressive, slow, web-paramminer | HTTP_RESPONSE, WEB_PARAMETER | FINDING, WEB_PARAMETER | @liquidsec | 2022-06-27 | +| paramminer_getparams | scan | No | Use smart brute-force to check for common HTTP GET parameters | active, aggressive, slow, web-paramminer | HTTP_RESPONSE, WEB_PARAMETER | FINDING, WEB_PARAMETER | @liquidsec | 2022-06-28 | +| paramminer_headers | scan | No | Use smart brute-force to check for common HTTP header parameters | active, aggressive, slow, web-paramminer | HTTP_RESPONSE, WEB_PARAMETER | WEB_PARAMETER | @pmueller | 2022-04-15 | +| portscan | scan | No | Port scan with masscan. By default, scans top 100 ports. | active, portscan, safe | DNS_NAME, IP_ADDRESS, IP_RANGE | OPEN_TCP_PORT | @TheTechromancer | 2024-05-15 | +| robots | scan | No | Look for and parse robots.txt | active, safe, web-basic | URL | URL_UNVERIFIED | @liquidsec | 2023-02-01 | +| secretsdb | scan | No | Detect common secrets with secrets-patterns-db | active, safe, web-basic | HTTP_RESPONSE | FINDING | @TheTechromancer | 2023-03-17 | +| smuggler | scan | No | Check for HTTP smuggling | active, aggressive, slow, web-thorough | URL | FINDING | @liquidsec | 2022-07-06 | +| sslcert | scan | No | Visit open ports and retrieve SSL certificates | active, affiliates, email-enum, safe, subdomain-enum, web-basic | OPEN_TCP_PORT | DNS_NAME, EMAIL_ADDRESS | @TheTechromancer | 2022-03-30 | +| telerik | scan | No | Scan for critical Telerik vulnerabilities | active, aggressive, web-thorough | HTTP_RESPONSE, URL | FINDING, VULNERABILITY | @liquidsec | 2022-04-10 | +| url_manipulation | scan | No | Attempt to identify URL parsing/routing based vulnerabilities | active, aggressive, web-thorough | URL | FINDING | @liquidsec | 2022-09-27 | +| vhost | scan | No | Fuzz for virtual hosts | active, aggressive, deadly, slow | URL | DNS_NAME, VHOST | @liquidsec | 2022-05-02 | +| wafw00f | scan | No | Web Application Firewall Fingerprinting Tool | active, aggressive | URL | WAF | @liquidsec | 2023-02-15 | +| wappalyzer | scan | No | Extract technologies from web responses | active, safe, web-basic | HTTP_RESPONSE | TECHNOLOGY | @liquidsec | 2022-04-15 | +| wpscan | scan | No | Wordpress security scanner. Highly recommended to use an API key for better results. | active, aggressive | HTTP_RESPONSE, TECHNOLOGY | FINDING, TECHNOLOGY, URL_UNVERIFIED, VULNERABILITY | @domwhewell-sage | 2024-05-29 | +| affiliates | scan | No | Summarize affiliate domains at the end of a scan | affiliates, passive, report, safe | * | | @TheTechromancer | 2022-07-25 | +| anubisdb | scan | No | Query jldc.me's database for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-10-04 | +| asn | scan | No | Query ripe and bgpview.io for ASNs | passive, report, safe, subdomain-enum | IP_ADDRESS | ASN | @TheTechromancer | 2022-07-25 | +| azure_realm | scan | No | Retrieves the "AuthURL" from login.microsoftonline.com/getuserrealm | affiliates, cloud-enum, passive, safe, subdomain-enum, web-basic | DNS_NAME | URL_UNVERIFIED | @TheTechromancer | 2023-07-12 | +| azure_tenant | scan | No | Query Azure for tenant sister domains | affiliates, cloud-enum, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2024-07-04 | +| bevigil | scan | Yes | Retrieve OSINT data from mobile applications using BeVigil | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, URL_UNVERIFIED | @alt-glitch | 2022-10-26 | +| binaryedge | scan | Yes | Query the BinaryEdge API | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2024-08-18 | +| bucket_file_enum | scan | No | Works in conjunction with the filedownload module to download files from open storage buckets. Currently supported cloud providers: AWS, DigitalOcean | cloud-enum, passive, safe | STORAGE_BUCKET | URL_UNVERIFIED | @TheTechromancer | 2023-11-14 | +| builtwith | scan | Yes | Query Builtwith.com for subdomains | affiliates, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-23 | +| c99 | scan | Yes | Query the C99 API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-08 | +| censys | scan | Yes | Query the Censys API | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-04 | +| certspotter | scan | No | Query Certspotter's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-28 | +| chaos | scan | Yes | Query ProjectDiscovery's Chaos API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-14 | +| code_repository | scan | No | Look for code repository links in webpages | code-enum, passive, safe | URL_UNVERIFIED | CODE_REPOSITORY | @domwhewell-sage | 2024-05-15 | +| columbus | scan | No | Query the Columbus Project API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2023-06-01 | +| credshed | scan | Yes | Send queries to your own credshed server to check for known credentials of your targets | passive, safe | DNS_NAME | EMAIL_ADDRESS, HASHED_PASSWORD, PASSWORD, USERNAME | @SpamFaux | 2023-10-12 | +| crobat | scan | No | Query Project Crobat for subdomains | passive, safe | DNS_NAME | DNS_NAME | @j3tj3rk | 2022-06-03 | +| crt | scan | No | Query crt.sh (certificate transparency) for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-05-13 | +| dehashed | scan | Yes | Execute queries against dehashed.com for exposed credentials | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS, HASHED_PASSWORD, PASSWORD, USERNAME | @SpamFaux | 2023-10-12 | +| digitorus | scan | No | Query certificatedetails.com for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2023-07-25 | +| dnsbrute | scan | No | Brute-force subdomains with massdns + static wordlist | aggressive, passive, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2024-04-24 | +| dnsbrute_mutations | scan | No | Brute-force subdomains with massdns + target-specific mutations | aggressive, passive, slow, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2024-04-25 | +| dnscaa | scan | No | Check for CAA records | email-enum, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, EMAIL_ADDRESS, URL_UNVERIFIED | @colin-stubbs | 2024-05-26 | +| dnscommonsrv | scan | No | Check for common SRV records | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-05-15 | +| dnsdumpster | scan | No | Query dnsdumpster for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-03-12 | +| docker_pull | scan | No | Download images from a docker repository | 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 | +| 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 | passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2024-03-08 | +| github_codesearch | scan | Yes | Query Github's API for code containing the target domain name | code-enum, passive, safe, subdomain-enum | DNS_NAME | CODE_REPOSITORY, URL_UNVERIFIED | @domwhewell-sage | 2023-12-14 | +| github_org | scan | No | Query Github's API for organization and member repositories | code-enum, passive, safe, subdomain-enum | ORG_STUB, SOCIAL | CODE_REPOSITORY | @domwhewell-sage | 2023-12-14 | +| github_workflows | scan | No | Download a github repositories workflow logs | passive, safe | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2024-04-29 | +| hackertarget | scan | No | Query the hackertarget.com API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-28 | +| hunterio | scan | Yes | Query hunter.io for emails | email-enum, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, EMAIL_ADDRESS, URL_UNVERIFIED | @TheTechromancer | 2022-04-25 | +| internetdb | scan | No | Query Shodan's InternetDB for open ports, hostnames, technologies, and vulnerabilities | passive, portscan, safe, subdomain-enum | DNS_NAME, IP_ADDRESS | DNS_NAME, FINDING, OPEN_TCP_PORT, TECHNOLOGY, VULNERABILITY | @TheTechromancer | 2023-12-22 | +| ip2location | scan | Yes | Query IP2location.io's API for geolocation information. | passive, safe | IP_ADDRESS | GEOLOCATION | @TheTechromancer | 2023-09-12 | +| ipneighbor | scan | No | Look beside IPs in their surrounding subnet | aggressive, passive, subdomain-enum | IP_ADDRESS | IP_ADDRESS | @TheTechromancer | 2022-06-08 | +| ipstack | scan | Yes | Query IPStack's GeoIP API | passive, safe | IP_ADDRESS | GEOLOCATION | @tycoonslive | 2022-11-26 | +| leakix | scan | No | Query leakix.net for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-11 | +| myssl | scan | No | Query myssl.com's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2023-07-10 | +| otx | scan | No | Query otx.alienvault.com for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-24 | +| passivetotal | scan | Yes | Query the PassiveTotal API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-08 | +| pgp | scan | No | Query common PGP servers for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | @TheTechromancer | 2022-08-10 | +| postman | scan | No | Query Postman's API for related workspaces, collections, requests | code-enum, passive, safe, subdomain-enum | DNS_NAME | URL_UNVERIFIED | @domwhewell-sage | 2023-12-23 | +| rapiddns | scan | No | Query rapiddns.io for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-24 | +| riddler | scan | No | Query riddler.io for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-25 | +| securitytrails | scan | Yes | Query the SecurityTrails API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-03 | +| shodan_dns | scan | Yes | Query Shodan for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-03 | +| sitedossier | scan | No | Query sitedossier.com for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2023-08-04 | +| skymem | scan | No | Query skymem.info for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | @TheTechromancer | 2022-07-11 | +| social | scan | No | Look for social media links in webpages | passive, safe, social-enum | URL_UNVERIFIED | SOCIAL | @TheTechromancer | 2023-03-28 | +| subdomaincenter | scan | No | Query subdomain.center's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2023-07-26 | +| sublist3r | scan | No | Query sublist3r's API for subdomains | passive, safe | DNS_NAME | DNS_NAME | @Want-EyeTea | 2022-03-29 | +| threatminer | scan | No | Query threatminer's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-28 | +| trufflehog | scan | No | TruffleHog is a tool for finding credentials | code-enum, passive, safe | FILESYSTEM | FINDING, VULNERABILITY | @domwhewell-sage | 2024-03-12 | +| unstructured | scan | No | Module to extract data from files | passive, safe | FILESYSTEM | FILESYSTEM, RAW_TEXT | @domwhewell-sage | 2024-06-03 | +| urlscan | scan | No | Query urlscan.io for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, URL_UNVERIFIED | @TheTechromancer | 2022-06-09 | +| viewdns | scan | No | Query viewdns.info's reverse whois for related domains | affiliates, passive, safe | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-07-04 | +| virustotal | scan | Yes | Query VirusTotal's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-25 | +| wayback | scan | No | Query archive.org's API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, URL_UNVERIFIED | @pmueller | 2022-04-01 | +| zoomeye | scan | Yes | Query ZoomEye's API for subdomains | affiliates, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-03 | +| asset_inventory | output | No | Merge hosts, open ports, technologies, findings, etc. into a single asset inventory CSV | | DNS_NAME, FINDING, HTTP_RESPONSE, IP_ADDRESS, OPEN_TCP_PORT, TECHNOLOGY, URL, VULNERABILITY, WAF | IP_ADDRESS, OPEN_TCP_PORT | @liquidsec | 2022-09-30 | +| csv | output | No | Output to CSV | | * | | @TheTechromancer | 2022-04-07 | +| discord | output | No | Message a Discord channel when certain events are encountered | | * | | @TheTechromancer | 2023-08-14 | +| emails | output | No | Output any email addresses found belonging to the target domain | email-enum | EMAIL_ADDRESS | | @domwhewell-sage | 2023-12-23 | +| http | output | No | Send every event to a custom URL via a web request | | * | | @TheTechromancer | 2022-04-13 | +| json | output | No | Output to Newline-Delimited JSON (NDJSON) | | * | | @TheTechromancer | 2022-04-07 | +| neo4j | output | No | Output to Neo4j | | * | | @TheTechromancer | 2022-04-07 | +| python | output | No | Output via Python API | | * | | @TheTechromancer | 2022-09-13 | +| slack | output | No | Message a Slack channel when certain events are encountered | | * | | @TheTechromancer | 2023-08-14 | +| splunk | output | No | Send every event to a splunk instance through HTTP Event Collector | | * | | @w0Tx | 2024-02-17 | +| stdout | output | No | Output to text | | * | | | | +| subdomains | output | No | Output only resolved, in-scope subdomains | subdomain-enum | DNS_NAME, DNS_NAME_UNRESOLVED | | @TheTechromancer | 2023-07-31 | +| teams | output | No | Message a Teams channel when certain events are encountered | | * | | @TheTechromancer | 2023-08-14 | +| txt | output | No | Output to text | | * | | | | +| web_report | output | No | Create a markdown report with web assets | | FINDING, TECHNOLOGY, URL, VHOST, VULNERABILITY | | @liquidsec | 2023-02-08 | +| websocket | output | No | Output to websockets | | * | | @TheTechromancer | 2022-04-15 | +| cloudcheck | internal | No | Tag events by cloud provider, identify cloud resources like storage buckets | | * | | | | +| dnsresolve | internal | No | | | * | | | | +| aggregate | internal | No | Summarize statistics at the end of a scan | passive, safe | | | @TheTechromancer | 2022-07-25 | +| excavate | internal | No | Passively extract juicy tidbits from scan data | passive | HTTP_RESPONSE | URL_UNVERIFIED, WEB_PARAMETER | @liquidsec | 2022-06-27 | +| speculate | internal | No | Derive certain event types from others by common sense | passive | AZURE_TENANT, DNS_NAME, DNS_NAME_UNRESOLVED, HTTP_RESPONSE, IP_ADDRESS, IP_RANGE, SOCIAL, STORAGE_BUCKET, URL, URL_UNVERIFIED, USERNAME | DNS_NAME, FINDING, IP_ADDRESS, OPEN_TCP_PORT, ORG_STUB | @liquidsec | 2022-05-03 | For a list of module config options, see [Module Options](../scanning/configuration.md#module-config-options). diff --git a/docs/modules/nuclei.md b/docs/modules/nuclei.md index 138b2b6ee..f0f3efed6 100644 --- a/docs/modules/nuclei.md +++ b/docs/modules/nuclei.md @@ -103,20 +103,20 @@ The **ratelimit** and **concurrency** settings default to the same defaults that ```bash # Scan a SINGLE target with a basic port scan and web modules -bbot -f web-basic -m nmap nuclei --allow-deadly -t app.evilcorp.com +bbot -f web-basic -m portscan nuclei --allow-deadly -t app.evilcorp.com ``` ```bash # Scanning MULTIPLE targets -bbot -f web-basic -m nmap nuclei --allow-deadly -t app1.evilcorp.com app2.evilcorp.com app3.evilcorp.com +bbot -f web-basic -m portscan nuclei --allow-deadly -t app1.evilcorp.com app2.evilcorp.com app3.evilcorp.com ``` ```bash # Scanning MULTIPLE targets while performing subdomain enumeration -bbot -f subdomain-enum web-basic -m nmap nuclei --allow-deadly -t app1.evilcorp.com app2.evilcorp.com app3.evilcorp.com +bbot -f subdomain-enum web-basic -m portscan nuclei --allow-deadly -t app1.evilcorp.com app2.evilcorp.com app3.evilcorp.com ``` ```bash # Scanning MULTIPLE targets on a BUDGET -bbot -f subdomain-enum web-basic -m nmap nuclei --allow-deadly -c modules.nuclei.mode=budget -t app1.evilcorp.com app2.evilcorp.com app3.evilcorp.com +bbot -f subdomain-enum web-basic -m portscan nuclei --allow-deadly -c modules.nuclei.mode=budget -t app1.evilcorp.com app2.evilcorp.com app3.evilcorp.com ``` diff --git a/docs/scanning/advanced.md b/docs/scanning/advanced.md index 58ffdf41d..a5dd5efd3 100644 --- a/docs/scanning/advanced.md +++ b/docs/scanning/advanced.md @@ -10,7 +10,7 @@ Below you can find some advanced uses of BBOT. from bbot.scanner import Scanner # any number of targets can be specified -scan = Scanner("example.com", "scanme.nmap.org", modules=["nmap", "sslcert"]) +scan = Scanner("example.com", "scanme.nmap.org", modules=["portscan", "sslcert"]) for event in scan.start(): print(event.json()) ``` @@ -21,7 +21,7 @@ for event in scan.start(): from bbot.scanner import Scanner async def main(): - scan = Scanner("example.com", "scanme.nmap.org", modules=["nmap", "sslcert"]) + scan = Scanner("example.com", "scanme.nmap.org", modules=["portscan", "sslcert"]) async for event in scan.async_start(): print(event.json()) @@ -33,22 +33,16 @@ asyncio.run(main()) ```text -usage: bbot [-h] [--help-all] [-t TARGET [TARGET ...]] - [-w WHITELIST [WHITELIST ...]] [-b BLACKLIST [BLACKLIST ...]] - [--strict-scope] [-m MODULE [MODULE ...]] [-l] - [-em MODULE [MODULE ...]] [-f FLAG [FLAG ...]] [-lf] - [-rf FLAG [FLAG ...]] [-ef FLAG [FLAG ...]] - [-om MODULE [MODULE ...]] [--allow-deadly] [-n SCAN_NAME] - [-o DIR] [-c [CONFIG ...]] [-v] [-d] [-s] [--force] [-y] - [--dry-run] [--current-config] - [--no-deps | --force-deps | --retry-deps | --ignore-failed-deps | --install-all-deps] - [-a] [--version] +usage: bbot [-h] [-t TARGET [TARGET ...]] [-w WHITELIST [WHITELIST ...]] [-b BLACKLIST [BLACKLIST ...]] [--strict-scope] [-p [PRESET ...]] [-c [CONFIG ...]] [-lp] + [-m MODULE [MODULE ...]] [-l] [-lmo] [-em MODULE [MODULE ...]] [-f FLAG [FLAG ...]] [-lf] [-rf FLAG [FLAG ...]] [-ef FLAG [FLAG ...]] [--allow-deadly] [-n SCAN_NAME] [-v] + [-d] [-s] [--force] [-y] [--dry-run] [--current-preset] [--current-preset-full] [-o DIR] [-om MODULE [MODULE ...]] [--json] [--brief] + [--event-types EVENT_TYPES [EVENT_TYPES ...]] [--no-deps | --force-deps | --retry-deps | --ignore-failed-deps | --install-all-deps] [--version] + [-H CUSTOM_HEADERS [CUSTOM_HEADERS ...]] [--custom-yara-rules CUSTOM_YARA_RULES] Bighuge BLS OSINT Tool options: -h, --help show this help message and exit - --help-all Display full help including module config options Target: -t TARGET [TARGET ...], --targets TARGET [TARGET ...] @@ -59,36 +53,52 @@ Target: Don't touch these things --strict-scope Don't consider subdomains of target/whitelist to be in-scope +Presets: + -p [PRESET ...], --preset [PRESET ...] + Enable BBOT preset(s) + -c [CONFIG ...], --config [CONFIG ...] + Custom config options in key=value format: e.g. 'modules.shodan.api_key=1234' + -lp, --list-presets List available presets. + Modules: -m MODULE [MODULE ...], --modules MODULE [MODULE ...] - Modules to enable. Choices: affiliates,ajaxpro,anubisdb,asn,azure_realm,azure_tenant,baddns,baddns_zone,badsecrets,bevigil,binaryedge,bucket_amazon,bucket_azure,bucket_digitalocean,bucket_file_enum,bucket_firebase,bucket_google,builtwith,bypass403,c99,censys,certspotter,chaos,code_repository,columbus,credshed,crobat,crt,dastardly,dehashed,digitorus,dnscaa,dnscommonsrv,dnsdumpster,docker_pull,dockerhub,dotnetnuke,emailformat,ffuf,ffuf_shortnames,filedownload,fingerprintx,fullhunt,generic_ssrf,git,git_clone,github_codesearch,github_org,github_workflows,gitlab,gowitness,hackertarget,host_header,httpx,hunt,hunterio,iis_shortnames,internetdb,ip2location,ipneighbor,ipstack,leakix,masscan,massdns,myssl,newsletters,nmap,ntlm,nuclei,oauth,otx,paramminer_cookies,paramminer_getparams,paramminer_headers,passivetotal,pgp,postman,rapiddns,riddler,robots,secretsdb,securitytrails,shodan_dns,sitedossier,skymem,smuggler,social,sslcert,subdomaincenter,sublist3r,telerik,threatminer,trufflehog,unstructured,url_manipulation,urlscan,vhost,viewdns,virustotal,wafw00f,wappalyzer,wayback,wpscan,zoomeye + Modules to enable. Choices: trufflehog,asn,crobat,hackertarget,ffuf,sublist3r,crt,myssl,pgp,smuggler,gitlab,azure_tenant,paramminer_cookies,wayback,badsecrets,binaryedge,vhost,robots,ipneighbor,dnscaa,wappalyzer,generic_ssrf,newsletters,hunterio,filedownload,builtwith,ip2location,paramminer_getparams,viewdns,host_header,github_org,oauth,c99,bucket_amazon,telerik,credshed,github_codesearch,virustotal,skymem,otx,nuclei,ffuf_shortnames,httpx,urlscan,secretsdb,dastardly,dnsdumpster,ajaxpro,wafw00f,sslcert,emailformat,ipstack,portscan,anubisdb,wpscan,code_repository,chaos,iis_shortnames,bucket_google,zoomeye,securitytrails,paramminer_headers,bucket_file_enum,hunt,dehashed,azure_realm,git,censys,docker_pull,bucket_digitalocean,bypass403,dnsbrute,dockerhub,fullhunt,bucket_firebase,columbus,sitedossier,baddns,passivetotal,dnsbrute_mutations,github_workflows,digitorus,fingerprintx,postman,ntlm,unstructured,dnscommonsrv,dotnetnuke,bevigil,social,affiliates,shodan_dns,url_manipulation,leakix,internetdb,rapiddns,threatminer,bucket_azure,baddns_zone,certspotter,subdomaincenter,git_clone,gowitness,riddler -l, --list-modules List available modules. + -lmo, --list-module-options + Show all module config options -em MODULE [MODULE ...], --exclude-modules MODULE [MODULE ...] Exclude these modules. -f FLAG [FLAG ...], --flags FLAG [FLAG ...] - Enable modules by flag. Choices: active,affiliates,aggressive,baddns,cloud-enum,deadly,email-enum,iis-shortnames,passive,portscan,repo-enum,report,safe,service-enum,slow,social-enum,subdomain-enum,subdomain-hijack,web-basic,web-paramminer,web-screenshots,web-thorough + Enable modules by flag. Choices: web-paramminer,safe,web-basic,iis-shortnames,report,aggressive,social-enum,email-enum,affiliates,code-enum,deadly,baddns,passive,cloud-enum,web-screenshots,portscan,subdomain-enum,web-thorough,slow,subdomain-hijack,service-enum,active -lf, --list-flags List available flags. -rf FLAG [FLAG ...], --require-flags FLAG [FLAG ...] Only enable modules with these flags (e.g. -rf passive) -ef FLAG [FLAG ...], --exclude-flags FLAG [FLAG ...] Disable modules with these flags. (e.g. -ef aggressive) - -om MODULE [MODULE ...], --output-modules MODULE [MODULE ...] - Output module(s). Choices: asset_inventory,csv,discord,emails,http,human,json,neo4j,python,slack,splunk,subdomains,teams,web_report,websocket --allow-deadly Enable the use of highly aggressive modules Scan: -n SCAN_NAME, --name SCAN_NAME Name of scan (default: random) - -o DIR, --output-dir DIR - -c [CONFIG ...], --config [CONFIG ...] - custom config file, or configuration options in key=value format: 'modules.shodan.api_key=1234' -v, --verbose Be more verbose -d, --debug Enable debugging -s, --silent Be quiet - --force Run scan even if module setups fail + --force Run scan even in the case of condition violations or failed module setups -y, --yes Skip scan confirmation prompt --dry-run Abort before executing scan - --current-config Show current config in YAML format + --current-preset Show the current preset in YAML format + --current-preset-full + Show the current preset in its full form, including defaults + +Output: + -o DIR, --output-dir DIR + Directory to output scan results + -om MODULE [MODULE ...], --output-modules MODULE [MODULE ...] + Output module(s). Choices: emails,subdomains,splunk,csv,websocket,asset_inventory,slack,stdout,http,discord,json,teams,python,txt,web_report,neo4j + --json, -j Output scan data in JSON format + --brief, -br Output only the data itself + --event-types EVENT_TYPES [EVENT_TYPES ...] + Choose which event types to display Module dependencies: Control how modules install their dependencies @@ -99,37 +109,39 @@ Module dependencies: --ignore-failed-deps Run modules even if they have failed dependencies --install-all-deps Install dependencies for all modules -Agent: - Report back to a central server - - -a, --agent-mode Start in agent mode - Misc: --version show BBOT version and exit + -H CUSTOM_HEADERS [CUSTOM_HEADERS ...], --custom-headers CUSTOM_HEADERS [CUSTOM_HEADERS ...] + List of custom headers as key value pairs (header=value). + --custom-yara-rules CUSTOM_YARA_RULES, -cy CUSTOM_YARA_RULES + Add custom yara rules to excavate EXAMPLES Subdomains: - bbot -t evilcorp.com -f subdomain-enum + bbot -t evilcorp.com -p subdomain-enum Subdomains (passive only): - bbot -t evilcorp.com -f subdomain-enum -rf passive + bbot -t evilcorp.com -p subdomain-enum -rf passive Subdomains + port scan + web screenshots: - bbot -t evilcorp.com -f subdomain-enum -m nmap gowitness -n my_scan -o . + bbot -t evilcorp.com -p subdomain-enum -m portscan gowitness -n my_scan -o . Subdomains + basic web scan: - bbot -t evilcorp.com -f subdomain-enum web-basic + bbot -t evilcorp.com -p subdomain-enum web-basic Web spider: - bbot -t www.evilcorp.com -m httpx robots badsecrets secretsdb -c web_spider_distance=2 web_spider_depth=2 + bbot -t www.evilcorp.com -p spider -c web.spider_distance=2 web.spider_depth=2 Everything everywhere all at once: - bbot -t evilcorp.com -f subdomain-enum email-enum cloud-enum web-basic -m nmap gowitness nuclei --allow-deadly + bbot -t evilcorp.com -p kitchen-sink List modules: bbot -l + List presets: + bbot -lp + List flags: bbot -lf diff --git a/docs/scanning/configuration.md b/docs/scanning/configuration.md index cf77737aa..b1847431b 100644 --- a/docs/scanning/configuration.md +++ b/docs/scanning/configuration.md @@ -1,6 +1,8 @@ # Configuration Overview -BBOT has a YAML config at `~/.config/bbot`. This config is different from the command-line arguments. This is where you change settings such as BBOT's **HTTP proxy**, **rate limits**, or global **User-Agent**. It's also where you put modules' **API keys**. +Normally, [Presets](presets.md) are used to configure a scan. However, there may be cases where you want to change BBOT's global defaults so a certain option is always set, even if it's not specified in a preset. + +BBOT has a YAML config at `~/.config/bbot.yml`. This is the first config that BBOT loads, so it's a good place to put default settings like `http_proxy`, `max_threads`, or `http_user_agent`. You can also put any module settings here, including **API keys**. For a list of all possible config options, see: @@ -11,13 +13,13 @@ For examples of common config changes, see [Tips and Tricks](tips_and_tricks.md) ## Configuration Files -BBOT loads its config from the following files, in this order: +BBOT loads its config from the following files, in this order (last one loaded == highest priority): -- `~/.config/bbot/bbot.yml` <-- Use this one as your main config -- `~/.config/bbot/secrets.yml` <-- Use this one for sensitive stuff like API keys -- command line (`--config`) <-- Use this to specify a custom config file or override individual config options +- `~/.config/bbot/bbot.yml` <-- Global BBOT config +- presets (`-p`) <-- Presets are good for scan-specific settings +- command line (`-c`) <-- CLI overrides everything -These config files will be automatically created for you when you first run BBOT. +`bbot.yml` will be automatically created for you when you first run BBOT. ## YAML Config vs Command Line @@ -25,7 +27,7 @@ You can specify config options either via the command line or the config. For ex ```bash # send BBOT traffic through an HTTP proxy -bbot -t evilcorp.com --config http_proxy=http://127.0.0.1:8080 +bbot -t evilcorp.com -c http_proxy=http://127.0.0.1:8080 ``` Or, in `~/.config/bbot/config.yml`: @@ -36,7 +38,7 @@ http_proxy: http://127.0.0.1:8080 These two are equivalent. -Config options specified via the command-line take precedence over all others. You can give BBOT a custom config file with `--config myconf.yml`, or individual arguments like this: `--config modules.shodan_dns.api_key=deadbeef`. To display the full and current BBOT config, including any command-line arguments, use `bbot --current-config`. +Config options specified via the command-line take precedence over all others. You can give BBOT a custom config file with `-c myconf.yml`, or individual arguments like this: `-c modules.shodan_dns.api_key=deadbeef`. To display the full and current BBOT config, including any command-line arguments, use `bbot -c`. Note that placing the following in `bbot.yml`: ```yaml title="~/.bbot/config/bbot.yml" @@ -46,7 +48,7 @@ modules: ``` Is the same as: ```bash -bbot --config modules.shodan_dns.api_key=deadbeef +bbot -c modules.shodan_dns.api_key=deadbeef ``` ## Global Config Options @@ -59,47 +61,110 @@ Below is a full list of the config options supported, along with their defaults. # BBOT working directory home: ~/.bbot -# Don't output events that are further than this from the main scope -# 1 == 1 hope away from main scope -# 0 == in scope only -scope_report_distance: 0 -# Generate new DNS_NAME and IP_ADDRESS events through DNS resolution -dns_resolution: true -# Limit the number of BBOT threads -max_threads: 25 -# Rate-limit DNS -dns_queries_per_second: 1000 -# Rate-limit HTTP -web_requests_per_second: 100 +# How many scan results to keep before cleaning up the older ones +keep_scans: 20 # Interval for displaying status messages status_frequency: 15 -# HTTP proxy -http_proxy: -# Web user-agent -user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.2151.97 # Include the raw data of files (i.e. PDFs, web screenshots) as base64 in the event file_blobs: false # Include the raw data of directories (i.e. git repos) as tar.gz base64 in the event folder_blobs: false -### WEB SPIDER ### - -# Set the maximum number of HTTP links that can be followed in a row (0 == no spidering allowed) -web_spider_distance: 0 -# Set the maximum directory depth for the web spider -web_spider_depth: 1 -# Set the maximum number of links that can be followed per page -web_spider_links_per_page: 25 - +### SCOPE ### + +scope: + # Filter by scope distance which events are displayed in the output + # 0 == show only in-scope events (affiliates are always shown) + # 1 == show all events up to distance-1 (1 hop from target) + report_distance: 0 + # How far out from the main scope to search + # Do not change this setting unless you know what you're doing + search_distance: 0 + +### DNS ### + +dns: + # Completely disable DNS resolution (careful if you have IP whitelists/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 + # How many instances of the dns module to run concurrently + threads: 25 + # How many concurrent DNS resolvers to use when brute-forcing + # (under the hood this is passed through directly to massdns -s) + brute_threads: 1000 + # How far away from the main target to explore via DNS resolution (independent of scope.search_distance) + # This is safe to change + search_distance: 1 + # Limit how many DNS records can be followed in a row (stop malicious/runaway DNS records) + runaway_limit: 5 + # DNS query timeout + timeout: 5 + # How many times to retry DNS queries + retries: 1 + # Completely disable BBOT's DNS wildcard detection + wildcard_disable: False + # Disable BBOT's DNS wildcard detection for select domains + wildcard_ignore: [] + # How many sanity checks to make when verifying wildcard DNS + # Increase this value if BBOT's wildcard detection isn't working + wildcard_tests: 10 + # Skip DNS requests for a certain domain and rdtype after encountering this many timeouts or SERVFAILs + # This helps prevent faulty DNS servers from hanging up the scan + abort_threshold: 50 + # Don't show PTR records containing IP addresses + filter_ptrs: true + # Enable/disable debug messages for DNS queries + debug: false + # For performance reasons, always skip these DNS queries + # Microsoft's DNS infrastructure is misconfigured so that certain queries to mail.protection.outlook.com always time out + omit_queries: + - SRV:mail.protection.outlook.com + - CNAME:mail.protection.outlook.com + - TXT:mail.protection.outlook.com + +### WEB ### + +web: + # HTTP proxy + http_proxy: + # Web user-agent + user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.2151.97 + # Set the maximum number of HTTP links that can be followed in a row (0 == no spidering allowed) + spider_distance: 0 + # Set the maximum directory depth for the web spider + spider_depth: 1 + # Set the maximum number of links that can be followed per page + spider_links_per_page: 25 + # HTTP timeout (for Python requests; API calls, etc.) + http_timeout: 10 + # HTTP timeout (for httpx) + httpx_timeout: 5 + # Custom HTTP headers (e.g. cookies, etc.) + # in the format { "Header-Key": "header_value" } + # These are attached to all in-scope HTTP requests + # Note that some modules (e.g. github) may end up sending these to out-of-scope resources + http_headers: {} + # HTTP retries (for Python requests; API calls, etc.) + http_retries: 1 + # HTTP retries (for httpx) + httpx_retries: 1 + # Enable/disable debug messages for web requests/responses + debug: false + # Maximum number of HTTP redirects to follow + http_max_redirects: 5 + # Whether to verify SSL certificates + ssl_verify: false + +# Tool dependencies +deps: + ffuf: + version: "2.1.0" ### ADVANCED OPTIONS ### -# How far out from the main scope to search -scope_search_distance: 0 -# How far out from the main scope to resolve DNS names / IPs -scope_dns_search_distance: 1 -# Limit how many DNS records can be followed in a row (stop malicious/runaway DNS records) -dns_resolve_distance: 5 +# Load BBOT modules from these custom paths +module_paths: [] # Infer certain events from others, e.g. IPs from IP ranges, DNS_NAMEs from URLs, etc. speculate: True @@ -107,104 +172,75 @@ speculate: True excavate: True # Summarize activity at the end of a scan aggregate: True +# DNS resolution +dnsresolve: True +# Cloud provider tagging +cloudcheck: True + +# How to handle installation of module dependencies +# Choices are: +# - abort_on_failure (default) - if a module dependency fails to install, abort the scan +# - retry_failed - try again to install failed dependencies +# - ignore_failed - run the scan regardless of what happens with dependency installation +# - disable - completely disable BBOT's dependency system (you are responsible for installing tools, pip packages, etc.) +deps_behavior: abort_on_failure + +# Strip querystring from URLs by default +url_querystring_remove: True +# When query string is retained, by default collapse parameter values down to a single value per parameter +url_querystring_collapse: True -# HTTP timeout (for Python requests; API calls, etc.) -http_timeout: 10 -# HTTP timeout (for httpx) -httpx_timeout: 5 -# Custom HTTP headers (e.g. cookies, etc.) -# in the format { "Header-Key": "header_value" } -# These are attached to all in-scope HTTP requests -# Note that some modules (e.g. github) may end up sending these to out-of-scope resources -http_headers: {} -# HTTP retries (for Python requests; API calls, etc.) -http_retries: 1 -# HTTP retries (for httpx) -httpx_retries: 1 -# Enable/disable debug messages for web requests/responses -http_debug: false -# Maximum number of HTTP redirects to follow -http_max_redirects: 5 -# DNS query timeout -dns_timeout: 5 -# How many times to retry DNS queries -dns_retries: 1 -# Disable BBOT's smart DNS wildcard handling for select domains -dns_wildcard_ignore: [] -# How many sanity checks to make when verifying wildcard DNS -# Increase this value if BBOT's wildcard detection isn't working -dns_wildcard_tests: 10 -# Skip DNS requests for a certain domain and rdtype after encountering this many timeouts or SERVFAILs -# This helps prevent faulty DNS servers from hanging up the scan -dns_abort_threshold: 50 -# Don't show PTR records containing IP addresses -dns_filter_ptrs: true -# Enable/disable debug messages for dns queries -dns_debug: false -# Whether to verify SSL certificates -ssl_verify: false -# How many scan results to keep before cleaning up the older ones -keep_scans: 20 # Completely ignore URLs with these extensions url_extension_blacklist: - # images - - png - - jpg - - bmp - - ico - - jpeg - - gif - - svg - - webp - # web/fonts - - css - - woff - - woff2 - - ttf - - eot - - sass - - scss - # audio - - mp3 - - m4a - - wav - - flac - # video - - mp4 - - mkv - - avi - - wmv - - mov - - flv - - webm + # images + - png + - jpg + - bmp + - ico + - jpeg + - gif + - svg + - webp + # web/fonts + - css + - woff + - woff2 + - ttf + - eot + - sass + - scss + # audio + - mp3 + - m4a + - wav + - flac + # video + - mp4 + - mkv + - avi + - wmv + - mov + - flv + - webm # Distribute URLs with these extensions only to httpx (these are omitted from output) url_extension_httpx_only: - - js + - js # Don't output these types of events (they are still distributed to modules) omit_event_types: - - HTTP_RESPONSE - - RAW_TEXT - - URL_UNVERIFIED - - DNS_NAME_UNRESOLVED - - FILESYSTEM - # - IP_ADDRESS -# URL of BBOT server -agent_url: '' -# Agent Bearer authentication token -agent_token: '' + - HTTP_RESPONSE + - RAW_TEXT + - URL_UNVERIFIED + - DNS_NAME_UNRESOLVED + - FILESYSTEM + - WEB_PARAMETER + - RAW_DNS_RECORD + # - IP_ADDRESS # Custom interactsh server settings interactsh_server: null interactsh_token: null interactsh_disable: false -# For performance reasons, always skip these DNS queries -# Microsoft's DNS infrastructure is misconfigured so that certain queries to mail.protection.outlook.com always time out -dns_omit_queries: - - SRV:mail.protection.outlook.com - - CNAME:mail.protection.outlook.com - - TXT:mail.protection.outlook.com - # temporary fix to boost scan performance # TODO: remove this when https://github.com/blacklanternsecurity/bbot/issues/1252 is merged target_dns_regex_disable: false @@ -214,217 +250,221 @@ target_dns_regex_disable: false ## Module Config Options -Many modules accept their own configuration options. These options have the ability to change their behavior. For example, the `nmap` module accepts options for `ports`, `timing`, etc. Below is a list of all possible module config options. +Many modules accept their own configuration options. These options have the ability to change their behavior. For example, the `portscan` module accepts options for `ports`, `rate`, etc. Below is a list of all possible module config options. -| Config Option | Type | Description | Default | -|------------------------------------------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| modules.baddns.custom_nameservers | list | Force BadDNS to use a list of custom nameservers | [] | -| modules.baddns.only_high_confidence | bool | Do not emit low-confidence or generic detections | False | -| modules.baddns_zone.custom_nameservers | list | Force BadDNS to use a list of custom nameservers | [] | -| modules.baddns_zone.only_high_confidence | bool | Do not emit low-confidence or generic detections | False | -| modules.bucket_amazon.permutations | bool | Whether to try permutations | False | -| modules.bucket_azure.permutations | bool | Whether to try permutations | False | -| modules.bucket_digitalocean.permutations | bool | Whether to try permutations | False | -| modules.bucket_firebase.permutations | bool | Whether to try permutations | False | -| modules.bucket_google.permutations | bool | Whether to try permutations | False | -| modules.ffuf.extensions | str | Optionally include a list of extensions to extend the keyword with (comma separated) | | -| modules.ffuf.lines | int | take only the first N lines from the wordlist when finding directories | 5000 | -| modules.ffuf.max_depth | int | the maximum directory depth to attempt to solve | 0 | -| modules.ffuf.version | str | ffuf version | 2.0.0 | -| modules.ffuf.wordlist | str | Specify wordlist to use when finding directories | https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-small-directories.txt | -| modules.ffuf_shortnames.extensions | str | Optionally include a list of extensions to extend the keyword with (comma separated) | | -| modules.ffuf_shortnames.find_common_prefixes | bool | Attempt to automatically detect common prefixes and make additional ffuf runs against them | False | -| modules.ffuf_shortnames.find_delimiters | bool | Attempt to detect common delimiters and make additional ffuf runs against them | True | -| modules.ffuf_shortnames.ignore_redirects | bool | Explicitly ignore redirects (301,302) | True | -| modules.ffuf_shortnames.lines | int | take only the first N lines from the wordlist when finding directories | 1000000 | -| modules.ffuf_shortnames.max_depth | int | the maximum directory depth to attempt to solve | 1 | -| modules.ffuf_shortnames.version | str | ffuf version | 2.0.0 | -| modules.ffuf_shortnames.wordlist | str | Specify wordlist to use when finding directories | | -| modules.ffuf_shortnames.wordlist_extensions | str | Specify wordlist to use when making extension lists | | -| modules.filedownload.base_64_encoded_file | str | Stream the bytes of a file and encode them in base 64 for event data. | false | -| modules.filedownload.extensions | list | File extensions to download | ['bak', 'bash', 'bashrc', 'conf', 'cfg', 'crt', 'csv', 'db', 'sqlite', 'doc', 'docx', 'exe', 'ica', 'indd', 'ini', 'jar', 'key', 'pub', 'log', 'markdown', 'md', 'msi', 'odg', 'odp', 'ods', 'odt', 'pdf', 'pem', 'pps', 'ppsx', 'ppt', 'pptx', 'ps1', 'raw', 'rdp', 'sh', 'sql', 'swp', 'sxw', 'tar', 'tar.gz', 'zip', 'txt', 'vbs', 'wpd', 'xls', 'xlsx', 'xml', 'yml', 'yaml'] | -| modules.filedownload.max_filesize | str | Cancel download if filesize is greater than this size | 10MB | -| modules.fingerprintx.skip_common_web | bool | Skip common web ports such as 80, 443, 8080, 8443, etc. | True | -| modules.fingerprintx.version | str | fingerprintx version | 1.1.4 | -| modules.gitlab.api_key | str | Gitlab access token | | -| modules.gowitness.idle_timeout | int | Skip the current gowitness batch if it stalls for longer than this many seconds | 1800 | -| modules.gowitness.output_path | str | Where to save screenshots | | -| modules.gowitness.resolution_x | int | Screenshot resolution x | 1440 | -| modules.gowitness.resolution_y | int | Screenshot resolution y | 900 | -| modules.gowitness.social | bool | Whether to screenshot social media webpages | True | -| modules.gowitness.threads | int | How many gowitness threads to spawn (default is number of CPUs x 2) | 0 | -| modules.gowitness.timeout | int | Preflight check timeout | 10 | -| modules.gowitness.version | str | Gowitness version | 2.4.2 | -| modules.httpx.in_scope_only | bool | Only visit web resources that are in scope. | True | -| modules.httpx.max_response_size | int | Max response size in bytes | 5242880 | -| modules.httpx.probe_all_ips | bool | Probe all the ips associated with same host | False | -| modules.httpx.store_responses | bool | Save raw HTTP responses to scan folder | False | -| modules.httpx.threads | int | Number of httpx threads to use | 50 | -| modules.httpx.version | str | httpx version | 1.2.5 | -| modules.iis_shortnames.detect_only | bool | Only detect the vulnerability and do not run the shortname scanner | True | -| modules.iis_shortnames.max_node_count | int | Limit how many nodes to attempt to resolve on any given recursion branch | 50 | -| modules.masscan.ping_first | bool | Only portscan hosts that reply to pings | False | -| modules.masscan.ping_only | bool | Ping sweep only, no portscan | False | -| modules.masscan.ports | str | Ports to scan | | -| modules.masscan.rate | int | Rate in packets per second | 600 | -| modules.masscan.top_ports | int | Top ports to scan (default 100) (to override, specify 'ports') | 100 | -| modules.masscan.use_cache | bool | Instead of scanning, use the results from the previous scan | False | -| modules.masscan.wait | int | Seconds to wait for replies after scan is complete | 5 | -| modules.nmap.ports | str | Ports to scan | | -| modules.nmap.skip_host_discovery | bool | skip host discovery (-Pn) | True | -| modules.nmap.timing | str |` -T<0-5>: Set timing template (higher is faster) `| T4 | -| modules.nmap.top_ports | int | Top ports to scan (default 100) (to override, specify 'ports') | 100 | -| modules.ntlm.try_all | bool | Try every NTLM endpoint | False | -| modules.nuclei.batch_size | int | Number of targets to send to Nuclei per batch (default 200) | 200 | -| modules.nuclei.budget | int | Used in budget mode to set the number of requests which will be allotted to the nuclei scan | 1 | -| modules.nuclei.concurrency | int | maximum number of templates to be executed in parallel (default 25) | 25 | -| modules.nuclei.directory_only | bool | Filter out 'file' URL event (default True) | True | -| modules.nuclei.etags | str | tags to exclude from the scan | | -| modules.nuclei.mode | str | manual | technology | severe | budget. Technology: Only activate based on technology events that match nuclei tags (nuclei -as mode). Manual (DEFAULT): Fully manual settings. Severe: Only critical and high severity templates without intrusive. Budget: Limit Nuclei to a specified number of HTTP requests | manual | -| modules.nuclei.ratelimit | int | maximum number of requests to send per second (default 150) | 150 | -| modules.nuclei.retries | int | number of times to retry a failed request (default 0) | 0 | -| modules.nuclei.severity | str | Filter based on severity field available in the template. | | -| modules.nuclei.silent | bool | Don't display nuclei's banner or status messages | False | -| modules.nuclei.tags | str | execute a subset of templates that contain the provided tags | | -| modules.nuclei.templates | str | template or template directory paths to include in the scan | | -| modules.nuclei.version | str | nuclei version | 3.2.0 | -| modules.oauth.try_all | bool | Check for OAUTH/IODC on every subdomain and URL. | False | -| modules.paramminer_cookies.http_extract | bool | Attempt to find additional wordlist words from the HTTP Response | True | -| modules.paramminer_cookies.skip_boring_words | bool | Remove commonly uninteresting words from the wordlist | True | -| modules.paramminer_cookies.wordlist | str | Define the wordlist to be used to derive cookies | | -| modules.paramminer_getparams.http_extract | bool | Attempt to find additional wordlist words from the HTTP Response | True | -| modules.paramminer_getparams.skip_boring_words | bool | Remove commonly uninteresting words from the wordlist | True | -| modules.paramminer_getparams.wordlist | str | Define the wordlist to be used to derive headers | | -| modules.paramminer_headers.http_extract | bool | Attempt to find additional wordlist words from the HTTP Response | True | -| modules.paramminer_headers.skip_boring_words | bool | Remove commonly uninteresting words from the wordlist | True | -| modules.paramminer_headers.wordlist | str | Define the wordlist to be used to derive headers | | -| modules.robots.include_allow | bool | Include 'Allow' Entries | True | -| modules.robots.include_disallow | bool | Include 'Disallow' Entries | True | -| modules.robots.include_sitemap | bool | Include 'sitemap' entries | False | -| modules.secretsdb.min_confidence | int | Only use signatures with this confidence score or higher | 99 | -| modules.secretsdb.signatures | str | File path or URL to YAML signatures | https://raw.githubusercontent.com/blacklanternsecurity/secrets-patterns-db/master/db/rules-stable.yml | -| modules.sslcert.skip_non_ssl | bool | Don't try common non-SSL ports | True | -| modules.sslcert.timeout | float | Socket connect timeout in seconds | 5.0 | -| modules.telerik.exploit_RAU_crypto | bool | Attempt to confirm any RAU AXD detections are vulnerable | False | -| modules.url_manipulation.allow_redirects | bool | Allowing redirects will sometimes create false positives. Disallowing will sometimes create false negatives. Allowed by default. | True | -| modules.vhost.force_basehost | str | Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL | | -| modules.vhost.lines | int | take only the first N lines from the wordlist when finding directories | 5000 | -| modules.vhost.wordlist | str | Wordlist containing subdomains | https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt | -| modules.wafw00f.generic_detect | bool | When no specific WAF detections are made, try to perform a generic detect | True | -| modules.wpscan.api_key | str | WPScan API Key | | -| modules.wpscan.connection_timeout | int | The connection timeout in seconds (default 30) | 30 | -| modules.wpscan.disable_tls_checks | bool | Disables the SSL/TLS certificate verification (Default True) | True | -| modules.wpscan.enumerate | str | Enumeration Process see wpscan help documentation (default: vp,vt,tt,cb,dbe,u,m) | vp,vt,tt,cb,dbe,u,m | -| modules.wpscan.force | bool | Do not check if the target is running WordPress or returns a 403 | False | -| modules.wpscan.request_timeout | int | The request timeout in seconds (default 60) | 60 | -| modules.wpscan.threads | int | How many wpscan threads to spawn (default is 5) | 5 | -| modules.anubisdb.limit | int | Limit the number of subdomains returned per query (increasing this may slow the scan due to garbage results from this API) | 1000 | -| modules.bevigil.api_key | str | BeVigil OSINT API Key | | -| modules.bevigil.urls | bool | Emit URLs in addition to DNS_NAMEs | False | -| modules.binaryedge.api_key | str | BinaryEdge API key | | -| modules.binaryedge.max_records | int | Limit results to help prevent exceeding API quota | 1000 | -| modules.bucket_file_enum.file_limit | int | Limit the number of files downloaded per bucket | 50 | -| modules.builtwith.api_key | str | Builtwith API key | | -| modules.builtwith.redirects | bool | Also look up inbound and outbound redirects | True | -| modules.c99.api_key | str | c99.nl API key | | -| modules.censys.api_id | str | Censys.io API ID | | -| modules.censys.api_secret | str | Censys.io API Secret | | -| modules.censys.max_pages | int | Maximum number of pages to fetch (100 results per page) | 5 | -| modules.chaos.api_key | str | Chaos API key | | -| modules.credshed.credshed_url | str | URL of credshed server | | -| modules.credshed.password | str | Credshed password | | -| modules.credshed.username | str | Credshed username | | -| modules.dehashed.api_key | str | DeHashed API Key | | -| modules.dehashed.username | str | Email Address associated with your API key | | -| modules.dnscaa.dns_names | bool | emit DNS_NAME events | True | -| modules.dnscaa.emails | bool | emit EMAIL_ADDRESS events | True | -| modules.dnscaa.in_scope_only | bool | Only check in-scope domains | True | -| modules.dnscaa.urls | bool | emit URL_UNVERIFIED events | True | -| modules.dnscommonsrv.max_event_handlers | int | How many instances of the module to run concurrently | 10 | -| modules.dnscommonsrv.top | int | How many of the top SRV records to check | 50 | -| 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 | | -| 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 | | -| modules.github_codesearch.api_key | str | Github token | | -| modules.github_codesearch.limit | int | Limit code search to this many results | 100 | -| modules.github_org.api_key | str | Github token | | -| modules.github_org.include_member_repos | bool | Also enumerate organization members' repositories | False | -| modules.github_org.include_members | bool | Enumerate organization members | True | -| modules.github_workflows.api_key | str | Github token | | -| modules.github_workflows.num_logs | int | For each workflow fetch the last N successful runs logs (max 100) | 1 | -| modules.hunterio.api_key | str | Hunter.IO API key | | -| modules.internetdb.show_open_ports | bool | Display OPEN_TCP_PORT events in output, even if they didn't lead to an interesting discovery | False | -| modules.ip2location.api_key | str | IP2location.io API Key | | -| modules.ip2location.lang | str | Translation information(ISO639-1). The translation is only applicable for continent, country, region and city name. | | -| modules.ipneighbor.num_bits | int | Netmask size (in CIDR notation) to check. Default is 4 bits (16 hosts) | 4 | -| modules.ipstack.api_key | str | IPStack GeoIP API Key | | -| modules.leakix.api_key | str | LeakIX API Key | | -| modules.massdns.max_depth | int | How many subdomains deep to brute force, i.e. 5.4.3.2.1.evilcorp.com | 5 | -| modules.massdns.max_mutations | int | Max number of smart mutations per subdomain | 500 | -| modules.massdns.max_resolvers | int | Number of concurrent massdns resolvers | 1000 | -| modules.massdns.wordlist | str | Subdomain wordlist URL | https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt | -| modules.passivetotal.api_key | str | RiskIQ API Key | | -| modules.passivetotal.username | str | RiskIQ Username | | -| modules.pgp.search_urls | list | PGP key servers to search |` ['https://keyserver.ubuntu.com/pks/lookup?fingerprint=on&op=vindex&search=', 'http://the.earth.li:11371/pks/lookup?fingerprint=on&op=vindex&search='] `| -| modules.securitytrails.api_key | str | SecurityTrails API key | | -| modules.shodan_dns.api_key | str | Shodan API key | | -| modules.trufflehog.concurrency | int | Number of concurrent workers | 8 | -| modules.trufflehog.only_verified | bool | Only report credentials that have been verified | True | -| modules.trufflehog.version | str | trufflehog version | 3.75.1 | -| modules.unstructured.extensions | list | File extensions to parse | ['bak', 'bash', 'bashrc', 'conf', 'cfg', 'crt', 'csv', 'db', 'sqlite', 'doc', 'docx', 'ica', 'indd', 'ini', 'key', 'pub', 'log', 'markdown', 'md', 'odg', 'odp', 'ods', 'odt', 'pdf', 'pem', 'pps', 'ppsx', 'ppt', 'pptx', 'ps1', 'rdp', 'sh', 'sql', 'swp', 'sxw', 'txt', 'vbs', 'wpd', 'xls', 'xlsx', 'xml', 'yml', 'yaml'] | -| modules.unstructured.ignore_folders | list | Subfolders to ignore when crawling downloaded folders | ['.git'] | -| modules.urlscan.urls | bool | Emit URLs in addition to DNS_NAMEs | False | -| modules.virustotal.api_key | str | VirusTotal API Key | | -| modules.wayback.garbage_threshold | int | Dedupe similar urls if they are in a group of this size or higher (lower values == less garbage data) | 10 | -| modules.wayback.urls | bool | emit URLs in addition to DNS_NAMEs | False | -| modules.zoomeye.api_key | str | ZoomEye API key | | -| modules.zoomeye.include_related | bool | Include domains which may be related to the target | False | -| modules.zoomeye.max_pages | int | How many pages of results to fetch | 20 | -| output_modules.asset_inventory.output_file | str | Set a custom output file | | -| output_modules.asset_inventory.recheck | bool | When use_previous=True, don't retain past details like open ports or findings. Instead, allow them to be rediscovered by the new scan | False | -| output_modules.asset_inventory.summary_netmask | int | Subnet mask to use when summarizing IP addresses at end of scan | 16 | -| output_modules.asset_inventory.use_previous | bool |` Emit previous asset inventory as new events (use in conjunction with -n ) `| False | -| output_modules.csv.output_file | str | Output to CSV file | | -| output_modules.discord.event_types | list | Types of events to send | ['VULNERABILITY', 'FINDING'] | -| output_modules.discord.min_severity | str | Only allow VULNERABILITY events of this severity or higher | LOW | -| output_modules.discord.webhook_url | str | Discord webhook URL | | -| output_modules.emails.output_file | str | Output to file | | -| output_modules.http.bearer | str | Authorization Bearer token | | -| output_modules.http.method | str | HTTP method | POST | -| output_modules.http.password | str | Password (basic auth) | | -| output_modules.http.siem_friendly | bool | Format JSON in a SIEM-friendly way for ingestion into Elastic, Splunk, etc. | False | -| output_modules.http.timeout | int | HTTP timeout | 10 | -| output_modules.http.url | str | Web URL | | -| output_modules.http.username | str | Username (basic auth) | | -| output_modules.human.console | bool | Output to console | True | -| output_modules.human.output_file | str | Output to file | | -| output_modules.json.console | bool | Output to console | False | -| output_modules.json.output_file | str | Output to file | | -| output_modules.json.siem_friendly | bool | Output JSON in a SIEM-friendly format for ingestion into Elastic, Splunk, etc. | False | -| output_modules.neo4j.password | str | Neo4j password | bbotislife | -| output_modules.neo4j.uri | str | Neo4j server + port | bolt://localhost:7687 | -| output_modules.neo4j.username | str | Neo4j username | neo4j | -| output_modules.slack.event_types | list | Types of events to send | ['VULNERABILITY', 'FINDING'] | -| output_modules.slack.min_severity | str | Only allow VULNERABILITY events of this severity or higher | LOW | -| output_modules.slack.webhook_url | str | Discord webhook URL | | -| output_modules.splunk.hectoken | str | HEC Token | | -| output_modules.splunk.index | str | Index to send data to | | -| output_modules.splunk.source | str | Source path to be added to the metadata | | -| output_modules.splunk.timeout | int | HTTP timeout | 10 | -| output_modules.splunk.url | str | Web URL | | -| output_modules.subdomains.include_unresolved | bool | Include unresolved subdomains in output | False | -| output_modules.subdomains.output_file | str | Output to file | | -| output_modules.teams.event_types | list | Types of events to send | ['VULNERABILITY', 'FINDING'] | -| output_modules.teams.min_severity | str | Only allow VULNERABILITY events of this severity or higher | LOW | -| output_modules.teams.webhook_url | str | Discord webhook URL | | -| output_modules.web_report.css_theme_file | str | CSS theme URL for HTML output | https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css | -| output_modules.web_report.output_file | str | Output to file | | -| output_modules.websocket.preserve_graph | bool | Preserve full chains of events in the graph (prevents orphans) | True | -| output_modules.websocket.token | str | Authorization Bearer token | | -| output_modules.websocket.url | str | Web URL | | -| internal_modules.speculate.max_hosts | int | Max number of IP_RANGE hosts to convert into IP_ADDRESS events | 65536 | -| internal_modules.speculate.ports | str | The set of ports to speculate on | 80,443 | +| Config Option | Type | Description | Default | +|------------------------------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| modules.baddns.custom_nameservers | list | Force BadDNS to use a list of custom nameservers | [] | +| modules.baddns.enable_references | bool | Enable the references module (off by default) | False | +| modules.baddns.only_high_confidence | bool | Do not emit low-confidence or generic detections | False | +| modules.baddns_zone.custom_nameservers | list | Force BadDNS to use a list of custom nameservers | [] | +| modules.baddns_zone.only_high_confidence | bool | Do not emit low-confidence or generic detections | False | +| modules.badsecrets.custom_secrets | NoneType | Include custom secrets loaded from a local file | None | +| modules.bucket_amazon.permutations | bool | Whether to try permutations | False | +| modules.bucket_azure.permutations | bool | Whether to try permutations | False | +| modules.bucket_digitalocean.permutations | bool | Whether to try permutations | False | +| modules.bucket_firebase.permutations | bool | Whether to try permutations | False | +| modules.bucket_google.permutations | bool | Whether to try permutations | False | +| modules.ffuf.extensions | str | Optionally include a list of extensions to extend the keyword with (comma separated) | | +| modules.ffuf.lines | int | take only the first N lines from the wordlist when finding directories | 5000 | +| modules.ffuf.max_depth | int | the maximum directory depth to attempt to solve | 0 | +| modules.ffuf.wordlist | str | Specify wordlist to use when finding directories | https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-small-directories.txt | +| modules.ffuf_shortnames.extensions | str | Optionally include a list of extensions to extend the keyword with (comma separated) | | +| modules.ffuf_shortnames.find_common_prefixes | bool | Attempt to automatically detect common prefixes and make additional ffuf runs against them | False | +| modules.ffuf_shortnames.find_delimiters | bool | Attempt to detect common delimiters and make additional ffuf runs against them | True | +| modules.ffuf_shortnames.ignore_redirects | bool | Explicitly ignore redirects (301,302) | True | +| modules.ffuf_shortnames.lines | int | take only the first N lines from the wordlist when finding directories | 1000000 | +| modules.ffuf_shortnames.max_depth | int | the maximum directory depth to attempt to solve | 1 | +| modules.ffuf_shortnames.version | str | ffuf version | 2.0.0 | +| modules.ffuf_shortnames.wordlist | str | Specify wordlist to use when finding directories | | +| modules.ffuf_shortnames.wordlist_extensions | str | Specify wordlist to use when making extension lists | | +| modules.filedownload.base_64_encoded_file | str | Stream the bytes of a file and encode them in base 64 for event data. | false | +| modules.filedownload.extensions | list | File extensions to download | ['bak', 'bash', 'bashrc', 'conf', 'cfg', 'crt', 'csv', 'db', 'sqlite', 'doc', 'docx', 'exe', 'ica', 'indd', 'ini', 'jar', 'key', 'pub', 'log', 'markdown', 'md', 'msi', 'odg', 'odp', 'ods', 'odt', 'pdf', 'pem', 'pps', 'ppsx', 'ppt', 'pptx', 'ps1', 'raw', 'rdp', 'sh', 'sql', 'swp', 'sxw', 'tar', 'tar.gz', 'zip', 'txt', 'vbs', 'wpd', 'xls', 'xlsx', 'xml', 'yml', 'yaml'] | +| modules.filedownload.max_filesize | str | Cancel download if filesize is greater than this size | 10MB | +| modules.fingerprintx.skip_common_web | bool | Skip common web ports such as 80, 443, 8080, 8443, etc. | True | +| modules.fingerprintx.version | str | fingerprintx version | 1.1.4 | +| modules.gitlab.api_key | str | Gitlab access token | | +| modules.gowitness.idle_timeout | int | Skip the current gowitness batch if it stalls for longer than this many seconds | 1800 | +| modules.gowitness.output_path | str | Where to save screenshots | | +| modules.gowitness.resolution_x | int | Screenshot resolution x | 1440 | +| modules.gowitness.resolution_y | int | Screenshot resolution y | 900 | +| modules.gowitness.social | bool | Whether to screenshot social media webpages | False | +| modules.gowitness.threads | int | How many gowitness threads to spawn (default is number of CPUs x 2) | 0 | +| modules.gowitness.timeout | int | Preflight check timeout | 10 | +| modules.gowitness.version | str | Gowitness version | 2.4.2 | +| modules.httpx.in_scope_only | bool | Only visit web reparents that are in scope. | True | +| modules.httpx.max_response_size | int | Max response size in bytes | 5242880 | +| modules.httpx.probe_all_ips | bool | Probe all the ips associated with same host | False | +| modules.httpx.store_responses | bool | Save raw HTTP responses to scan folder | False | +| modules.httpx.threads | int | Number of httpx threads to use | 50 | +| modules.httpx.version | str | httpx version | 1.2.5 | +| modules.iis_shortnames.detect_only | bool | Only detect the vulnerability and do not run the shortname scanner | True | +| modules.iis_shortnames.max_node_count | int | Limit how many nodes to attempt to resolve on any given recursion branch | 50 | +| modules.ntlm.try_all | bool | Try every NTLM endpoint | False | +| modules.nuclei.batch_size | int | Number of targets to send to Nuclei per batch (default 200) | 200 | +| modules.nuclei.budget | int | Used in budget mode to set the number of requests which will be allotted to the nuclei scan | 1 | +| modules.nuclei.concurrency | int | maximum number of templates to be executed in parallel (default 25) | 25 | +| modules.nuclei.directory_only | bool | Filter out 'file' URL event (default True) | True | +| modules.nuclei.etags | str | tags to exclude from the scan | | +| modules.nuclei.mode | str | manual | technology | severe | budget. Technology: Only activate based on technology events that match nuclei tags (nuclei -as mode). Manual (DEFAULT): Fully manual settings. Severe: Only critical and high severity templates without intrusive. Budget: Limit Nuclei to a specified number of HTTP requests | manual | +| modules.nuclei.ratelimit | int | maximum number of requests to send per second (default 150) | 150 | +| modules.nuclei.retries | int | number of times to retry a failed request (default 0) | 0 | +| modules.nuclei.severity | str | Filter based on severity field available in the template. | | +| modules.nuclei.silent | bool | Don't display nuclei's banner or status messages | False | +| modules.nuclei.tags | str | execute a subset of templates that contain the provided tags | | +| modules.nuclei.templates | str | template or template directory paths to include in the scan | | +| modules.nuclei.version | str | nuclei version | 3.2.0 | +| modules.oauth.try_all | bool | Check for OAUTH/IODC on every subdomain and URL. | False | +| modules.paramminer_cookies.recycle_words | bool | Attempt to use words found during the scan on all other endpoints | False | +| modules.paramminer_cookies.skip_boring_words | bool | Remove commonly uninteresting words from the wordlist | True | +| modules.paramminer_cookies.wordlist | str | Define the wordlist to be used to derive cookies | | +| modules.paramminer_getparams.recycle_words | bool | Attempt to use words found during the scan on all other endpoints | False | +| modules.paramminer_getparams.skip_boring_words | bool | Remove commonly uninteresting words from the wordlist | True | +| modules.paramminer_getparams.wordlist | str | Define the wordlist to be used to derive headers | | +| modules.paramminer_headers.recycle_words | bool | Attempt to use words found during the scan on all other endpoints | False | +| modules.paramminer_headers.skip_boring_words | bool | Remove commonly uninteresting words from the wordlist | True | +| modules.paramminer_headers.wordlist | str | Define the wordlist to be used to derive headers | | +| modules.portscan.adapter | str | Manually specify a network interface, such as "eth0" or "tun0". If not specified, the first network interface found with a default gateway will be used. | | +| modules.portscan.adapter_ip | str | Send packets using this IP address. Not needed unless masscan's autodetection fails | | +| modules.portscan.adapter_mac | str | Send packets using this as the source MAC address. Not needed unless masscan's autodetection fails | | +| modules.portscan.ping_first | bool | Only portscan hosts that reply to pings | False | +| modules.portscan.ping_only | bool | Ping sweep only, no portscan | False | +| modules.portscan.ports | str | Ports to scan | | +| modules.portscan.rate | int | Rate in packets per second | 300 | +| modules.portscan.router_mac | str | Send packets to this MAC address as the destination. Not needed unless masscan's autodetection fails | | +| modules.portscan.top_ports | int | Top ports to scan (default 100) (to override, specify 'ports') | 100 | +| modules.portscan.wait | int | Seconds to wait for replies after scan is complete | 5 | +| modules.robots.include_allow | bool | Include 'Allow' Entries | True | +| modules.robots.include_disallow | bool | Include 'Disallow' Entries | True | +| modules.robots.include_sitemap | bool | Include 'sitemap' entries | False | +| modules.secretsdb.min_confidence | int | Only use signatures with this confidence score or higher | 99 | +| modules.secretsdb.signatures | str | File path or URL to YAML signatures | https://raw.githubusercontent.com/blacklanternsecurity/secrets-patterns-db/master/db/rules-stable.yml | +| modules.sslcert.skip_non_ssl | bool | Don't try common non-SSL ports | True | +| modules.sslcert.timeout | float | Socket connect timeout in seconds | 5.0 | +| modules.telerik.exploit_RAU_crypto | bool | Attempt to confirm any RAU AXD detections are vulnerable | False | +| modules.url_manipulation.allow_redirects | bool | Allowing redirects will sometimes create false positives. Disallowing will sometimes create false negatives. Allowed by default. | True | +| modules.vhost.force_basehost | str | Use a custom base host (e.g. evilcorp.com) instead of the default behavior of using the current URL | | +| modules.vhost.lines | int | take only the first N lines from the wordlist when finding directories | 5000 | +| modules.vhost.wordlist | str | Wordlist containing subdomains | https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt | +| modules.wafw00f.generic_detect | bool | When no specific WAF detections are made, try to perform a generic detect | True | +| modules.wpscan.api_key | str | WPScan API Key | | +| modules.wpscan.connection_timeout | int | The connection timeout in seconds (default 30) | 30 | +| modules.wpscan.disable_tls_checks | bool | Disables the SSL/TLS certificate verification (Default True) | True | +| modules.wpscan.enumerate | str | Enumeration Process see wpscan help documentation (default: vp,vt,tt,cb,dbe,u,m) | vp,vt,tt,cb,dbe,u,m | +| modules.wpscan.force | bool | Do not check if the target is running WordPress or returns a 403 | False | +| modules.wpscan.request_timeout | int | The request timeout in seconds (default 60) | 60 | +| modules.wpscan.threads | int | How many wpscan threads to spawn (default is 5) | 5 | +| modules.anubisdb.limit | int | Limit the number of subdomains returned per query (increasing this may slow the scan due to garbage results from this API) | 1000 | +| modules.bevigil.api_key | str | BeVigil OSINT API Key | | +| modules.bevigil.urls | bool | Emit URLs in addition to DNS_NAMEs | False | +| modules.binaryedge.api_key | str | BinaryEdge API key | | +| modules.binaryedge.max_records | int | Limit results to help prevent exceeding API quota | 1000 | +| modules.bucket_file_enum.file_limit | int | Limit the number of files downloaded per bucket | 50 | +| modules.builtwith.api_key | str | Builtwith API key | | +| modules.builtwith.redirects | bool | Also look up inbound and outbound redirects | True | +| modules.c99.api_key | str | c99.nl API key | | +| modules.censys.api_id | str | Censys.io API ID | | +| modules.censys.api_secret | str | Censys.io API Secret | | +| modules.censys.max_pages | int | Maximum number of pages to fetch (100 results per page) | 5 | +| modules.chaos.api_key | str | Chaos API key | | +| modules.credshed.credshed_url | str | URL of credshed server | | +| modules.credshed.password | str | Credshed password | | +| modules.credshed.username | str | Credshed username | | +| modules.dehashed.api_key | str | DeHashed API Key | | +| modules.dehashed.username | str | Email Address associated with your API key | | +| modules.dnsbrute.max_depth | int | How many subdomains deep to brute force, i.e. 5.4.3.2.1.evilcorp.com | 5 | +| modules.dnsbrute.wordlist | str | Subdomain wordlist URL | https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt | +| modules.dnsbrute_mutations.max_mutations | int | Maximum number of target-specific mutations to try per subdomain | 100 | +| modules.dnscaa.dns_names | bool | emit DNS_NAME events | True | +| modules.dnscaa.emails | bool | emit EMAIL_ADDRESS events | True | +| modules.dnscaa.in_scope_only | bool | Only check in-scope domains | True | +| modules.dnscaa.urls | bool | emit URL_UNVERIFIED events | True | +| modules.dnscommonsrv.max_depth | int | The maximum subdomain depth to brute-force SRV records | 2 | +| 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 | | +| 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 | | +| modules.github_codesearch.api_key | str | Github token | | +| modules.github_codesearch.limit | int | Limit code search to this many results | 100 | +| modules.github_org.api_key | str | Github token | | +| modules.github_org.include_member_repos | bool | Also enumerate organization members' repositories | False | +| modules.github_org.include_members | bool | Enumerate organization members | True | +| modules.github_workflows.api_key | str | Github token | | +| modules.github_workflows.num_logs | int | For each workflow fetch the last N successful runs logs (max 100) | 1 | +| modules.hunterio.api_key | str | Hunter.IO API key | | +| modules.internetdb.show_open_ports | bool | Display OPEN_TCP_PORT events in output, even if they didn't lead to an interesting discovery | False | +| modules.ip2location.api_key | str | IP2location.io API Key | | +| modules.ip2location.lang | str | Translation information(ISO639-1). The translation is only applicable for continent, country, region and city name. | | +| modules.ipneighbor.num_bits | int | Netmask size (in CIDR notation) to check. Default is 4 bits (16 hosts) | 4 | +| modules.ipstack.api_key | str | IPStack GeoIP API Key | | +| modules.leakix.api_key | str | LeakIX API Key | | +| modules.passivetotal.api_key | str | RiskIQ API Key | | +| modules.passivetotal.username | str | RiskIQ Username | | +| modules.pgp.search_urls | list | PGP key servers to search |` ['https://keyserver.ubuntu.com/pks/lookup?fingerprint=on&op=vindex&search=', 'http://the.earth.li:11371/pks/lookup?fingerprint=on&op=vindex&search=', 'https://pgpkeys.eu/pks/lookup?search=&op=index', 'https://pgp.mit.edu/pks/lookup?search=&op=index'] `| +| modules.securitytrails.api_key | str | SecurityTrails API key | | +| modules.shodan_dns.api_key | str | Shodan API key | | +| modules.trufflehog.concurrency | int | Number of concurrent workers | 8 | +| modules.trufflehog.only_verified | bool | Only report credentials that have been verified | True | +| modules.trufflehog.version | str | trufflehog version | 3.75.1 | +| modules.unstructured.extensions | list | File extensions to parse | ['bak', 'bash', 'bashrc', 'conf', 'cfg', 'crt', 'csv', 'db', 'sqlite', 'doc', 'docx', 'ica', 'indd', 'ini', 'key', 'pub', 'log', 'markdown', 'md', 'odg', 'odp', 'ods', 'odt', 'pdf', 'pem', 'pps', 'ppsx', 'ppt', 'pptx', 'ps1', 'rdp', 'sh', 'sql', 'swp', 'sxw', 'txt', 'vbs', 'wpd', 'xls', 'xlsx', 'xml', 'yml', 'yaml'] | +| modules.unstructured.ignore_folders | list | Subfolders to ignore when crawling downloaded folders | ['.git'] | +| modules.urlscan.urls | bool | Emit URLs in addition to DNS_NAMEs | False | +| modules.virustotal.api_key | str | VirusTotal API Key | | +| modules.wayback.garbage_threshold | int | Dedupe similar urls if they are in a group of this size or higher (lower values == less garbage data) | 10 | +| modules.wayback.urls | bool | emit URLs in addition to DNS_NAMEs | False | +| modules.zoomeye.api_key | str | ZoomEye API key | | +| modules.zoomeye.include_related | bool | Include domains which may be related to the target | False | +| modules.zoomeye.max_pages | int | How many pages of results to fetch | 20 | +| modules.asset_inventory.output_file | str | Set a custom output file | | +| modules.asset_inventory.recheck | bool | When use_previous=True, don't retain past details like open ports or findings. Instead, allow them to be rediscovered by the new scan | False | +| modules.asset_inventory.summary_netmask | int | Subnet mask to use when summarizing IP addresses at end of scan | 16 | +| modules.asset_inventory.use_previous | bool |` Emit previous asset inventory as new events (use in conjunction with -n ) `| False | +| modules.csv.output_file | str | Output to CSV file | | +| modules.discord.event_types | list | Types of events to send | ['VULNERABILITY', 'FINDING'] | +| modules.discord.min_severity | str | Only allow VULNERABILITY events of this severity or higher | LOW | +| modules.discord.webhook_url | str | Discord webhook URL | | +| modules.emails.output_file | str | Output to file | | +| modules.http.bearer | str | Authorization Bearer token | | +| modules.http.method | str | HTTP method | POST | +| modules.http.password | str | Password (basic auth) | | +| modules.http.siem_friendly | bool | Format JSON in a SIEM-friendly way for ingestion into Elastic, Splunk, etc. | False | +| modules.http.timeout | int | HTTP timeout | 10 | +| modules.http.url | str | Web URL | | +| modules.http.username | str | Username (basic auth) | | +| modules.json.output_file | str | Output to file | | +| modules.json.siem_friendly | bool | Output JSON in a SIEM-friendly format for ingestion into Elastic, Splunk, etc. | False | +| modules.neo4j.password | str | Neo4j password | bbotislife | +| modules.neo4j.uri | str | Neo4j server + port | bolt://localhost:7687 | +| modules.neo4j.username | str | Neo4j username | neo4j | +| modules.slack.event_types | list | Types of events to send | ['VULNERABILITY', 'FINDING'] | +| modules.slack.min_severity | str | Only allow VULNERABILITY events of this severity or higher | LOW | +| modules.slack.webhook_url | str | Discord webhook URL | | +| modules.splunk.hectoken | str | HEC Token | | +| modules.splunk.index | str | Index to send data to | | +| modules.splunk.source | str | Source path to be added to the metadata | | +| modules.splunk.timeout | int | HTTP timeout | 10 | +| modules.splunk.url | str | Web URL | | +| modules.stdout.accept_dupes | bool | Whether to show duplicate events, default True | True | +| modules.stdout.event_fields | list | Which event fields to display | [] | +| modules.stdout.event_types | list | Which events to display, default all event types | [] | +| modules.stdout.format | str | Which text format to display, choices: text,json | text | +| modules.stdout.in_scope_only | bool | Whether to only show in-scope events | False | +| modules.subdomains.include_unresolved | bool | Include unresolved subdomains in output | False | +| modules.subdomains.output_file | str | Output to file | | +| modules.teams.event_types | list | Types of events to send | ['VULNERABILITY', 'FINDING'] | +| modules.teams.min_severity | str | Only allow VULNERABILITY events of this severity or higher | LOW | +| modules.teams.webhook_url | str | Discord webhook URL | | +| modules.txt.output_file | str | Output to file | | +| modules.web_report.css_theme_file | str | CSS theme URL for HTML output | https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css | +| modules.web_report.output_file | str | Output to file | | +| modules.websocket.preserve_graph | bool | Preserve full chains of events in the graph (prevents orphans) | True | +| modules.websocket.token | str | Authorization Bearer token | | +| modules.websocket.url | str | Web URL | | +| modules.excavate.custom_yara_rules | str | Include custom Yara rules | | +| modules.excavate.retain_querystring | bool | Keep the querystring intact on emitted WEB_PARAMETERS | False | +| modules.excavate.yara_max_match_data | int | Sets the maximum amount of text that can extracted from a YARA regex | 2000 | +| modules.speculate.max_hosts | int | Max number of IP_RANGE hosts to convert into IP_ADDRESS events | 65536 | +| modules.speculate.ports | str | The set of ports to speculate on | 80,443 | diff --git a/docs/scanning/events.md b/docs/scanning/events.md index 63d300660..8b61db7f5 100644 --- a/docs/scanning/events.md +++ b/docs/scanning/events.md @@ -10,9 +10,10 @@ event type event data source module tags In addition to the obvious data (e.g. `www.evilcorp.com`), an event also contains other useful information such as: +- a `.discovery_path` showing exactly how the event was discovered, starting from the first scan target - a `.timestamp` of when the data was discovered - the `.module` that discovered it -- the `.source` event that led to its discovery +- the `.parent` event that led to its discovery - its `.scope_distance` (how many hops it is from the main scope, 0 == in-scope) - a list of `.tags` that describe the data (`mx-record`, `http-title`, etc.) @@ -21,24 +22,41 @@ These attributes allow us to construct a visual graph of events (e.g. in [Neo4j] ```json { "type": "URL", - "id": "URL:017ec8e5dc158c0fd46f07169f8577fb4b45e89a", - "data": "http://www.blacklanternsecurity.com/", + "id": "URL:c9962277277393f8895d2a4fa9b7f70b15f3af3e", + "scope_description": "in-scope", + "data": "https://blog.blacklanternsecurity.com/", + "host": "blog.blacklanternsecurity.com", + "resolved_hosts": [ + "104.18.40.87" + ], + "dns_children": { + "A": [ + "104.18.40.87", + "172.64.147.169" + ] + }, "web_spider_distance": 0, "scope_distance": 0, - "scan": "SCAN:4d786912dbc97be199da13074699c318e2067a7f", - "timestamp": 1688526222.723366, - "resolved_hosts": ["185.199.108.153"], - "source": "OPEN_TCP_PORT:cf7e6a937b161217eaed99f0c566eae045d094c7", + "scan": "SCAN:9224b49405e6d1607fd615243577d9ca86c7d206", + "timestamp": 1717260760.157012, + "parent": "OPEN_TCP_PORT:ebe3d6c10b41f60e3590ce6436ab62510b91c758", "tags": [ "in-scope", - "distance-0", + "http-title-black-lantern-security-blsops", "dir", - "ip-185-199-108-153", - "status-301", - "http-title-301-moved-permanently" + "ip-104-18-40-87", + "cdn-cloudflare", + "status-200" ], "module": "httpx", - "module_sequence": "httpx" + "module_sequence": "httpx", + "discovery_context": "httpx visited blog.blacklanternsecurity.com:443 and got status code 200 at https://blog.blacklanternsecurity.com/", + "discovery_path": [ + "Scan difficult_arthur seeded with DNS_NAME: blacklanternsecurity.com", + "certspotter searched certspotter API for \"blacklanternsecurity.com\" and found DNS_NAME: blog.blacklanternsecurity.com", + "speculated OPEN_TCP_PORT: blog.blacklanternsecurity.com:443", + "httpx visited blog.blacklanternsecurity.com:443 and got status code 200 at https://blog.blacklanternsecurity.com/" + ] } ``` @@ -49,38 +67,39 @@ Below is a full list of event types along with which modules produce/consume the ## List of Event Types -| Event Type | # Consuming Modules | # Producing Modules | Consuming Modules | Producing Modules | -|---------------------|-----------------------|-----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| * | 12 | 0 | affiliates, csv, discord, http, human, json, neo4j, python, slack, splunk, teams, websocket | | -| ASN | 0 | 1 | | asn | -| AZURE_TENANT | 1 | 0 | speculate | | -| CODE_REPOSITORY | 3 | 5 | docker_pull, git_clone, github_workflows | code_repository, dockerhub, github_codesearch, github_org, gitlab | -| DNS_NAME | 58 | 43 | anubisdb, asset_inventory, azure_realm, azure_tenant, baddns, baddns_zone, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnscaa, dnscommonsrv, dnsdumpster, emailformat, fullhunt, github_codesearch, hackertarget, hunterio, internetdb, leakix, massdns, myssl, nmap, oauth, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, speculate, subdomaincenter, subdomains, sublist3r, threatminer, urlscan, viewdns, virustotal, wayback, zoomeye | anubisdb, azure_tenant, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crobat, crt, digitorus, dnscaa, dnscommonsrv, dnsdumpster, fullhunt, hackertarget, hunterio, internetdb, leakix, massdns, myssl, ntlm, oauth, otx, passivetotal, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, speculate, sslcert, subdomaincenter, sublist3r, threatminer, urlscan, vhost, viewdns, virustotal, wayback, zoomeye | -| DNS_NAME_UNRESOLVED | 3 | 0 | baddns, speculate, subdomains | | -| EMAIL_ADDRESS | 1 | 7 | emails | credshed, dnscaa, emailformat, hunterio, pgp, skymem, sslcert | -| FILESYSTEM | 2 | 5 | trufflehog, unstructured | docker_pull, filedownload, git_clone, github_workflows, unstructured | -| FINDING | 2 | 29 | asset_inventory, web_report | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, git, gitlab, host_header, hunt, internetdb, newsletters, ntlm, nuclei, paramminer_cookies, paramminer_getparams, paramminer_headers, secretsdb, smuggler, speculate, telerik, trufflehog, url_manipulation, wpscan | -| GEOLOCATION | 0 | 2 | | ip2location, ipstack | -| HASHED_PASSWORD | 0 | 2 | | credshed, dehashed | -| HTTP_RESPONSE | 20 | 1 | ajaxpro, asset_inventory, badsecrets, dastardly, dotnetnuke, excavate, filedownload, gitlab, host_header, hunt, newsletters, ntlm, paramminer_cookies, paramminer_getparams, paramminer_headers, secretsdb, speculate, telerik, wappalyzer, wpscan | httpx | -| IP_ADDRESS | 9 | 3 | asn, asset_inventory, internetdb, ip2location, ipneighbor, ipstack, masscan, nmap, speculate | asset_inventory, ipneighbor, speculate | -| IP_RANGE | 3 | 0 | masscan, nmap, speculate | | -| OPEN_TCP_PORT | 4 | 5 | asset_inventory, fingerprintx, httpx, sslcert | asset_inventory, internetdb, masscan, nmap, speculate | -| ORG_STUB | 2 | 1 | dockerhub, github_org | speculate | -| PASSWORD | 0 | 2 | | credshed, dehashed | -| PROTOCOL | 0 | 1 | | fingerprintx | -| RAW_TEXT | 1 | 1 | excavate | unstructured | -| SOCIAL | 5 | 3 | dockerhub, github_org, gitlab, gowitness, speculate | dockerhub, gitlab, social | -| STORAGE_BUCKET | 7 | 5 | bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, speculate | bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google | -| TECHNOLOGY | 4 | 8 | asset_inventory, gitlab, web_report, wpscan | badsecrets, dotnetnuke, gitlab, gowitness, internetdb, nuclei, wappalyzer, wpscan | -| URL | 19 | 2 | ajaxpro, asset_inventory, bypass403, ffuf, generic_ssrf, git, gowitness, httpx, iis_shortnames, ntlm, nuclei, robots, smuggler, speculate, telerik, url_manipulation, vhost, wafw00f, web_report | gowitness, httpx | -| URL_HINT | 1 | 1 | ffuf_shortnames | iis_shortnames | -| URL_UNVERIFIED | 6 | 16 | code_repository, filedownload, httpx, oauth, social, speculate | azure_realm, bevigil, bucket_file_enum, dnscaa, dockerhub, excavate, ffuf, ffuf_shortnames, github_codesearch, gowitness, hunterio, postman, robots, urlscan, wayback, wpscan | -| USERNAME | 1 | 2 | speculate | credshed, dehashed | -| VHOST | 1 | 1 | web_report | vhost | -| VULNERABILITY | 2 | 12 | asset_inventory, web_report | ajaxpro, baddns, baddns_zone, badsecrets, dastardly, dotnetnuke, generic_ssrf, internetdb, nuclei, telerik, trufflehog, wpscan | -| WAF | 1 | 1 | asset_inventory | wafw00f | -| WEBSCREENSHOT | 0 | 1 | | gowitness | +| Event Type | # Consuming Modules | # Producing Modules | Consuming Modules | Producing Modules | +|---------------------|-----------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| * | 15 | 0 | affiliates, cloudcheck, csv, discord, dnsresolve, http, json, neo4j, python, slack, splunk, stdout, teams, txt, websocket | | +| ASN | 0 | 1 | | asn | +| AZURE_TENANT | 1 | 0 | speculate | | +| CODE_REPOSITORY | 3 | 5 | docker_pull, git_clone, github_workflows | code_repository, dockerhub, github_codesearch, github_org, gitlab | +| DNS_NAME | 59 | 44 | anubisdb, asset_inventory, azure_realm, azure_tenant, baddns, baddns_zone, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, columbus, credshed, crobat, crt, dehashed, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, emailformat, fullhunt, github_codesearch, hackertarget, hunterio, internetdb, leakix, myssl, oauth, otx, passivetotal, pgp, portscan, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, speculate, subdomaincenter, subdomains, sublist3r, threatminer, urlscan, viewdns, virustotal, wayback, zoomeye | anubisdb, azure_tenant, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crobat, crt, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, fullhunt, hackertarget, hunterio, internetdb, leakix, myssl, ntlm, oauth, otx, passivetotal, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, speculate, sslcert, subdomaincenter, sublist3r, threatminer, urlscan, vhost, viewdns, virustotal, wayback, zoomeye | +| DNS_NAME_UNRESOLVED | 3 | 0 | baddns, speculate, subdomains | | +| EMAIL_ADDRESS | 1 | 8 | emails | credshed, dehashed, dnscaa, emailformat, hunterio, pgp, skymem, sslcert | +| FILESYSTEM | 2 | 5 | trufflehog, unstructured | docker_pull, filedownload, git_clone, github_workflows, unstructured | +| FINDING | 2 | 28 | asset_inventory, web_report | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, git, gitlab, host_header, hunt, internetdb, newsletters, ntlm, nuclei, paramminer_cookies, paramminer_getparams, secretsdb, smuggler, speculate, telerik, trufflehog, url_manipulation, wpscan | +| GEOLOCATION | 0 | 2 | | ip2location, ipstack | +| HASHED_PASSWORD | 0 | 2 | | credshed, dehashed | +| HTTP_RESPONSE | 19 | 1 | ajaxpro, asset_inventory, badsecrets, dastardly, dotnetnuke, excavate, filedownload, gitlab, host_header, newsletters, ntlm, paramminer_cookies, paramminer_getparams, paramminer_headers, secretsdb, speculate, telerik, wappalyzer, wpscan | httpx | +| IP_ADDRESS | 8 | 3 | asn, asset_inventory, internetdb, ip2location, ipneighbor, ipstack, portscan, speculate | asset_inventory, ipneighbor, speculate | +| IP_RANGE | 2 | 0 | portscan, speculate | | +| OPEN_TCP_PORT | 4 | 4 | asset_inventory, fingerprintx, httpx, sslcert | asset_inventory, internetdb, portscan, speculate | +| ORG_STUB | 2 | 1 | dockerhub, github_org | speculate | +| PASSWORD | 0 | 2 | | credshed, dehashed | +| PROTOCOL | 0 | 1 | | fingerprintx | +| RAW_TEXT | 0 | 1 | | unstructured | +| SOCIAL | 5 | 3 | dockerhub, github_org, gitlab, gowitness, speculate | dockerhub, gitlab, social | +| STORAGE_BUCKET | 7 | 5 | bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, speculate | bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google | +| TECHNOLOGY | 4 | 8 | asset_inventory, gitlab, web_report, wpscan | badsecrets, dotnetnuke, gitlab, gowitness, internetdb, nuclei, wappalyzer, wpscan | +| URL | 19 | 2 | ajaxpro, asset_inventory, bypass403, ffuf, generic_ssrf, git, gowitness, httpx, iis_shortnames, ntlm, nuclei, robots, smuggler, speculate, telerik, url_manipulation, vhost, wafw00f, web_report | gowitness, httpx | +| URL_HINT | 1 | 1 | ffuf_shortnames | iis_shortnames | +| URL_UNVERIFIED | 6 | 16 | code_repository, filedownload, httpx, oauth, social, speculate | azure_realm, bevigil, bucket_file_enum, dnscaa, dockerhub, excavate, ffuf, ffuf_shortnames, github_codesearch, gowitness, hunterio, postman, robots, urlscan, wayback, wpscan | +| USERNAME | 1 | 2 | speculate | credshed, dehashed | +| VHOST | 1 | 1 | web_report | vhost | +| VULNERABILITY | 2 | 12 | asset_inventory, web_report | ajaxpro, baddns, baddns_zone, badsecrets, dastardly, dotnetnuke, generic_ssrf, internetdb, nuclei, telerik, trufflehog, wpscan | +| WAF | 1 | 1 | asset_inventory | wafw00f | +| WEBSCREENSHOT | 0 | 1 | | gowitness | +| WEB_PARAMETER | 4 | 4 | hunt, paramminer_cookies, paramminer_getparams, paramminer_headers | excavate, paramminer_cookies, paramminer_getparams, paramminer_headers | ## Findings Vs. Vulnerabilities diff --git a/docs/scanning/index.md b/docs/scanning/index.md index b2b9271fe..adc8e27f6 100644 --- a/docs/scanning/index.md +++ b/docs/scanning/index.md @@ -107,30 +107,30 @@ A single module can have multiple flags. For example, the `securitytrails` modul ### List of Flags -| Flag | # Modules | Description | Modules | -|------------------|-------------|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| safe | 84 | Non-intrusive, safe to run | affiliates, aggregate, ajaxpro, anubisdb, asn, azure_realm, azure_tenant, baddns, baddns_zone, badsecrets, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, code_repository, columbus, credshed, crobat, crt, dehashed, digitorus, dnscaa, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, filedownload, fingerprintx, fullhunt, git, git_clone, github_codesearch, github_org, github_workflows, gitlab, gowitness, hackertarget, httpx, hunt, hunterio, iis_shortnames, internetdb, ip2location, ipstack, leakix, myssl, newsletters, ntlm, oauth, otx, passivetotal, pgp, postman, rapiddns, riddler, robots, secretsdb, securitytrails, shodan_dns, sitedossier, skymem, social, sslcert, subdomaincenter, sublist3r, threatminer, trufflehog, unstructured, urlscan, viewdns, virustotal, wappalyzer, wayback, zoomeye | -| passive | 64 | Never connects to target systems | affiliates, aggregate, anubisdb, asn, azure_realm, azure_tenant, bevigil, binaryedge, bucket_file_enum, builtwith, c99, censys, certspotter, chaos, code_repository, columbus, credshed, crobat, crt, dehashed, digitorus, dnscaa, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, excavate, fullhunt, git_clone, github_codesearch, github_org, github_workflows, hackertarget, hunterio, internetdb, ip2location, ipneighbor, ipstack, leakix, massdns, myssl, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, social, speculate, subdomaincenter, sublist3r, threatminer, trufflehog, unstructured, urlscan, viewdns, virustotal, wayback, zoomeye | -| subdomain-enum | 46 | Enumerates subdomains | anubisdb, asn, azure_realm, azure_tenant, baddns_zone, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crt, digitorus, dnscaa, dnscommonsrv, dnsdumpster, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, internetdb, ipneighbor, leakix, massdns, myssl, oauth, otx, passivetotal, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, sslcert, subdomaincenter, subdomains, threatminer, urlscan, virustotal, wayback, zoomeye | -| active | 43 | Makes active connections to target systems | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab, gowitness, host_header, httpx, hunt, iis_shortnames, masscan, newsletters, nmap, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wappalyzer, wpscan | -| web-thorough | 29 | More advanced web scanning functionality | ajaxpro, azure_realm, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf_shortnames, filedownload, generic_ssrf, git, host_header, httpx, hunt, iis_shortnames, nmap, ntlm, oauth, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, wappalyzer | -| aggressive | 21 | Generates a large amount of network traffic | bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, generic_ssrf, host_header, ipneighbor, masscan, massdns, nmap, nuclei, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, telerik, url_manipulation, vhost, wafw00f, wpscan | -| web-basic | 17 | Basic, non-intrusive web scan functionality | azure_realm, baddns, badsecrets, bucket_amazon, bucket_azure, bucket_firebase, bucket_google, filedownload, git, httpx, iis_shortnames, ntlm, oauth, robots, secretsdb, sslcert, wappalyzer | -| cloud-enum | 12 | Enumerates cloud resources | azure_realm, azure_tenant, baddns, baddns_zone, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, httpx, oauth | -| slow | 10 | May take a long time to complete | bucket_digitalocean, dastardly, docker_pull, fingerprintx, git_clone, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, vhost | -| affiliates | 8 | Discovers affiliated hostnames/domains | affiliates, azure_realm, azure_tenant, builtwith, oauth, sslcert, viewdns, zoomeye | -| email-enum | 8 | Enumerates email addresses | dehashed, dnscaa, emailformat, emails, hunterio, pgp, skymem, sslcert | -| deadly | 4 | Highly aggressive | dastardly, ffuf, nuclei, vhost | -| portscan | 3 | Discovers open ports | internetdb, masscan, nmap | -| web-paramminer | 3 | Discovers HTTP parameters through brute-force | paramminer_cookies, paramminer_getparams, paramminer_headers | -| baddns | 2 | Runs all modules from the DNS auditing tool BadDNS | baddns, baddns_zone | -| iis-shortnames | 2 | Scans for IIS Shortname vulnerability | ffuf_shortnames, iis_shortnames | -| report | 2 | Generates a report at the end of the scan | affiliates, asn | -| social-enum | 2 | Enumerates social media | httpx, social | -| repo-enum | 1 | Enumerates code repositories | code_repository | -| service-enum | 1 | Identifies protocols running on open ports | fingerprintx | -| subdomain-hijack | 1 | Detects hijackable subdomains | baddns | -| web-screenshots | 1 | Takes screenshots of web pages | gowitness | +| Flag | # Modules | Description | Modules | +|------------------|-------------|----------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| safe | 85 | Non-intrusive, safe to run | affiliates, aggregate, ajaxpro, anubisdb, asn, azure_realm, azure_tenant, baddns, baddns_zone, badsecrets, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, code_repository, columbus, credshed, crobat, crt, dehashed, digitorus, dnscaa, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, filedownload, fingerprintx, fullhunt, git, git_clone, github_codesearch, github_org, github_workflows, gitlab, gowitness, hackertarget, httpx, hunt, hunterio, iis_shortnames, internetdb, ip2location, ipstack, leakix, myssl, newsletters, ntlm, oauth, otx, passivetotal, pgp, portscan, postman, rapiddns, riddler, robots, secretsdb, securitytrails, shodan_dns, sitedossier, skymem, social, sslcert, subdomaincenter, sublist3r, threatminer, trufflehog, unstructured, urlscan, viewdns, virustotal, wappalyzer, wayback, zoomeye | +| passive | 65 | Never connects to target systems | affiliates, aggregate, anubisdb, asn, azure_realm, azure_tenant, bevigil, binaryedge, bucket_file_enum, builtwith, c99, censys, certspotter, chaos, code_repository, columbus, credshed, crobat, crt, dehashed, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, docker_pull, dockerhub, emailformat, excavate, fullhunt, git_clone, github_codesearch, github_org, github_workflows, hackertarget, hunterio, internetdb, ip2location, ipneighbor, ipstack, leakix, myssl, otx, passivetotal, pgp, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, skymem, social, speculate, subdomaincenter, sublist3r, threatminer, trufflehog, unstructured, urlscan, viewdns, virustotal, wayback, zoomeye | +| subdomain-enum | 47 | Enumerates subdomains | anubisdb, asn, azure_realm, azure_tenant, baddns_zone, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crt, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, internetdb, ipneighbor, leakix, myssl, oauth, otx, passivetotal, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, sslcert, subdomaincenter, subdomains, threatminer, urlscan, virustotal, wayback, zoomeye | +| active | 42 | Makes active connections to target systems | ajaxpro, baddns, baddns_zone, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf, ffuf_shortnames, filedownload, fingerprintx, generic_ssrf, git, gitlab, gowitness, host_header, httpx, hunt, iis_shortnames, newsletters, ntlm, nuclei, oauth, paramminer_cookies, paramminer_getparams, paramminer_headers, portscan, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, vhost, wafw00f, wappalyzer, wpscan | +| aggressive | 20 | Generates a large amount of network traffic | bypass403, dastardly, dnsbrute, dnsbrute_mutations, dotnetnuke, ffuf, ffuf_shortnames, generic_ssrf, host_header, ipneighbor, nuclei, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, telerik, url_manipulation, vhost, wafw00f, wpscan | +| web-basic | 17 | Basic, non-intrusive web scan functionality | azure_realm, baddns, badsecrets, bucket_amazon, bucket_azure, bucket_firebase, bucket_google, filedownload, git, httpx, iis_shortnames, ntlm, oauth, robots, secretsdb, sslcert, wappalyzer | +| cloud-enum | 12 | Enumerates cloud resources | azure_realm, azure_tenant, baddns, baddns_zone, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, httpx, oauth | +| web-thorough | 12 | More advanced web scanning functionality | ajaxpro, bucket_digitalocean, bypass403, dastardly, dotnetnuke, ffuf_shortnames, generic_ssrf, host_header, hunt, smuggler, telerik, url_manipulation | +| slow | 11 | May take a long time to complete | bucket_digitalocean, dastardly, dnsbrute_mutations, docker_pull, fingerprintx, git_clone, paramminer_cookies, paramminer_getparams, paramminer_headers, smuggler, vhost | +| affiliates | 8 | Discovers affiliated hostnames/domains | affiliates, azure_realm, azure_tenant, builtwith, oauth, sslcert, viewdns, zoomeye | +| code-enum | 8 | Find public code repositories and search them for secrets etc. | code_repository, dockerhub, git, github_codesearch, github_org, gitlab, postman, trufflehog | +| email-enum | 8 | Enumerates email addresses | dehashed, dnscaa, emailformat, emails, hunterio, pgp, skymem, sslcert | +| deadly | 4 | Highly aggressive | dastardly, ffuf, nuclei, vhost | +| web-paramminer | 3 | Discovers HTTP parameters through brute-force | paramminer_cookies, paramminer_getparams, paramminer_headers | +| baddns | 2 | Runs all modules from the DNS auditing tool BadDNS | baddns, baddns_zone | +| iis-shortnames | 2 | Scans for IIS Shortname vulnerability | ffuf_shortnames, iis_shortnames | +| portscan | 2 | Discovers open ports | internetdb, portscan | +| report | 2 | Generates a report at the end of the scan | affiliates, asn | +| social-enum | 2 | Enumerates social media | httpx, social | +| service-enum | 1 | Identifies protocols running on open ports | fingerprintx | +| subdomain-hijack | 1 | Detects hijackable subdomains | baddns | +| web-screenshots | 1 | Takes screenshots of web pages | gowitness | ## Dependencies @@ -161,11 +161,11 @@ Scope distance continues to increase the further out you get. Most modules (e.g. #### Displaying Out-of-scope Events -By default, BBOT only displays in-scope events (with a few exceptions such as `STORAGE_BUCKET`s). If you want to see more, you must increase the [config](configuration.md) value of `scope_report_distance`: +By default, BBOT only displays in-scope events (with a few exceptions such as `STORAGE_BUCKET`s). If you want to see more, you must increase the [config](configuration.md) value of `scope.report_distance`: ```bash # display out-of-scope events up to one hop away from the main scope -bbot -t evilcorp.com -f subdomain-enum -c scope_report_distance=1 +bbot -t evilcorp.com -f subdomain-enum -c scope.report_distance=1 ``` ### Strict Scope @@ -211,16 +211,18 @@ Wildcard hosts are collapsed into a single host beginning with `_wildcard`: If you don't want this, you can disable wildcard detection on a domain-to-domain basis in the [config](configuration.md): ```yaml title="~/.bbot/config/bbot.yml" -dns_wildcard_ignore: - - evilcorp.com - - evilcorp.co.uk +dns: + wildcard_ignore: + - evilcorp.com + - evilcorp.co.uk ``` There are certain edge cases (such as with dynamic DNS rules) where BBOT's wildcard detection fails. In these cases, you can try increasing the number of wildcard checks in the config: ```yaml title="~/.bbot/config/bbot.yml" # default == 10 -dns_wildcard_tests: 20 +dns: + wildcard_tests: 20 ``` If that doesn't work you can consider [blacklisting](#whitelists-and-blacklists) the offending domain. diff --git a/docs/scanning/output.md b/docs/scanning/output.md index 4acd25250..55eaa5469 100644 --- a/docs/scanning/output.md +++ b/docs/scanning/output.md @@ -11,9 +11,23 @@ If you reuse a scan name, it will append to its original output files and levera Multiple simultaneous output formats are possible because of **output modules**. Output modules are similar to normal modules except they are enabled with `-om`. -### Human +### STDOUT -`human` output is tab-delimited, so it's easy to grep: +The `stdout` output module is what you see when you execute BBOT in the terminal. By default it looks the same as the [`txt`](#txt) module, but it has options you can customize. You can filter by event type, choose the data format (`text`, `json`), and which fields you want to see: + + +| Config Option | Type | Description | Default | +|------------------------------|--------|--------------------------------------------------|-----------| +| modules.stdout.accept_dupes | bool | Whether to show duplicate events, default True | True | +| modules.stdout.event_fields | list | Which event fields to display | [] | +| modules.stdout.event_types | list | Which events to display, default all event types | [] | +| modules.stdout.format | str | Which text format to display, choices: text,json | text | +| modules.stdout.in_scope_only | bool | Whether to only show in-scope events | False | + + +### TXT + +`txt` output is tab-delimited, so it's easy to grep: ```bash # grep out only the DNS_NAMEs @@ -53,7 +67,7 @@ You will then see [events](events.md) like this: "scan": "SCAN:64c0e076516ae7aa6502fd99489693d0d5ec26cc", "timestamp": 1688518967.740472, "resolved_hosts": ["1.2.3.4"], - "source": "DNS_NAME:2da045542abbf86723f22383d04eb453e573723c", + "parent": "DNS_NAME:2da045542abbf86723f22383d04eb453e573723c", "tags": ["distance-1", "ipv4", "internal"], "module": "A", "module_sequence": "A" @@ -64,7 +78,7 @@ You can filter on the JSON output with `jq`: ```bash # pull out only the .data attribute of every DNS_NAME -$ jq -r 'select(.type=="DNS_NAME") | .data' ~/.bbot/scans/extreme_johnny/output.ndjson +$ jq -r 'select(.type=="DNS_NAME") | .data' ~/.bbot/scans/extreme_johnny/output.json evilcorp.com www.evilcorp.com mail.evilcorp.com @@ -77,20 +91,20 @@ mail.evilcorp.com BBOT supports output via webhooks to `discord`, `slack`, and `teams`. To use them, you must specify a webhook URL either in the config: ```yaml title="~/.bbot/config/bbot.yml" -output_modules: +modules: discord: webhook_url: https://discord.com/api/webhooks/1234/deadbeef ``` ...or on the command line: ```bash -bbot -t evilcorp.com -om discord -c output_modules.discord.webhook_url=https://discord.com/api/webhooks/1234/deadbeef +bbot -t evilcorp.com -om discord -c modules.discord.webhook_url=https://discord.com/api/webhooks/1234/deadbeef ``` By default, only `VULNERABILITY` and `FINDING` events are sent, but this can be customized by setting `event_types` in the config like so: ```yaml title="~/.bbot/config/bbot.yml" -output_modules: +modules: discord: event_types: - VULNERABILITY @@ -100,14 +114,14 @@ output_modules: ...or on the command line: ```bash -bbot -t evilcorp.com -om discord -c output_modules.discord.event_types=["STORAGE_BUCKET","FINDING","VULNERABILITY"] +bbot -t evilcorp.com -om discord -c modules.discord.event_types=["STORAGE_BUCKET","FINDING","VULNERABILITY"] ``` You can also filter on the severity of `VULNERABILITY` events by setting `min_severity`: ```yaml title="~/.bbot/config/bbot.yml" -output_modules: +modules: discord: min_severity: HIGH ``` @@ -118,13 +132,13 @@ The `http` output module sends [events](events.md) in JSON format to a desired H ```bash # POST scan results to localhost -bbot -t evilcorp.com -om http -c output_modules.http.url=http://localhost:8000 +bbot -t evilcorp.com -om http -c modules.http.url=http://localhost:8000 ``` You can customize the HTTP method if needed. Authentication is also supported: ```yaml title="~/.bbot/config/bbot.yml" -output_modules: +modules: http: url: https://localhost:8000 method: PUT @@ -142,7 +156,7 @@ The `splunk` output module sends [events](events.md) in JSON format to a desired You can customize this output with the following config options: ```yaml title="~/.bbot/config/bbot.yml" -output_modules: +modules: splunk: # The full URL with the URI `/services/collector/event` url: https://localhost:8088/services/collector/event diff --git a/docs/scanning/presets.md b/docs/scanning/presets.md new file mode 100644 index 000000000..8814e5b47 --- /dev/null +++ b/docs/scanning/presets.md @@ -0,0 +1,185 @@ +# Presets + +Once you start customizing BBOT, your commands can start to get really long. Presets let you put all your scan settings in a single file: + +```bash +bbot -t my_preset.yml +``` + +A Preset is a YAML file that can include scan targets, modules, and config options like API keys. + +A typical preset looks like this: + + +```yaml title="subdomain-enum.yml" +description: Enumerate subdomains via APIs, brute-force + +flags: + - subdomain-enum + +output_modules: + - subdomains + +``` + + +## How to use Presets (`-p`) + +BBOT has a ready-made collection of presets for common tasks like subdomain enumeration and web spidering. They live in `~/.bbot/presets`. + +To list them, you can do: + +```bash +# list available presets +bbot -lp +``` + +Enable them with `-p`: + +```bash +# do a subdomain enumeration +bbot -t evilcorp.com -p subdomain-enum + +# multiple presets - subdomain enumeration + web spider +bbot -t evilcorp.com -p subdomain-enum spider + +# start with a preset but only enable modules that have the 'passive' flag +bbot -t evilcorp.com -p subdomain-enum -rf passive + +# preset + manual config override +bbot -t www.evilcorp.com -p spider -c web.spider_distance=10 +``` + +You can build on the default presets, or create your own. Here's an example of a custom preset that builds on `subdomain-enum`: + +```yaml title="my_subdomains.yml" +description: Do a subdomain enumeration + basic web scan + nuclei + +target: + - evilcorp.com + +include: + # include these default presets + - subdomain-enum + - web-basic + +modules: + # enable nuclei in addition to the other modules + - nuclei + +config: + # global config options + http_proxy: http://127.0.0.1:8080 + # module config options + modules: + # api keys + securitytrails: + api_key: 21a270d5f59c9b05813a72bb41707266 + virustotal: + api_key: 4f41243847da693a4f356c0486114bc6 +``` + +To execute your custom preset, you do: + +```bash +bbot -p ./my_subdomains.yml +``` + +## Preset Load Order + +When you enable multiple presets, the order matters. In the case of a conflict, the last preset will always win. This means, for example, if you have a custom preset called `my_spider` that sets `web.spider_distance` to 1: + +```yaml title="my_spider.yml" +config: + web: + spider_distance: 1 +``` + +...and you enable it alongside the default `spider` preset in this order: + +```bash +bbot -t evilcorp.com -p ./my_spider.yml spider +``` + +...the value of `web.spider_distance` will be overridden by `spider`. To ensure this doesn't happen, you would want to switch the order of the presets: + +```bash +bbot -t evilcorp.com -p spider ./my_spider.yml +``` + +## Validating Presets + +To make sure BBOT is configured the way you expect, you can always check the `--current-preset` to show the final verison of the config that will be used when BBOT executes: + +```bash +# verify the preset is what you want +bbot -p ./mypreset.yml --current-preset +``` + +## Advanced Usage + +BBOT Presets support advanced features like environment variable substitution and custom conditions. + +### Environment Variables + +You can insert environment variables into your preset like this: `${env:}`: + +```yaml title="my_nuclei.yml" +description: Do a nuclei scan + +target: + - evilcorp.com + +modules: + - nuclei + +config: + modules: + nuclei: + # allow the nuclei templates to be specified at runtime via an environment variable + tags: ${env:NUCLEI_TAGS} +``` + +```bash +NUCLEI_TAGS=apache,nginx bbot -p ./my_nuclei.yml +``` + +### Conditions + +Sometimes, you might need to add custom logic to a preset. BBOT supports this via `conditions`. The `conditions` attribute allows you to specify a list of custom conditions that will be evaluated before the scan starts. This is useful for performing last-minute sanity checks, or changing the behavior of the scan based on custom criteria. + +```yaml title="my_preset.yml" +description: Abort if nuclei templates aren't specified + +modules: + - nuclei + +conditions: + - | + {% if not config.modules.nuclei.templates %} + {{ abort("Don't forget to set your templates!") }} + {% endif %} +``` + +```yaml title="my_preset.yml" +description: Enable ffuf but only when the web spider isn't also enabled + +modules: + - ffuf + +conditions: + - | + {% if config.web.spider_distance > 0 and config.web.spider_depth > 0 %} + {{ warn("Disabling ffuf because the web spider is enabled") }} + {{ preset.exclude_module("ffuf") }} + {% endif %} +``` + +Conditions use [Jinja](https://palletsprojects.com/p/jinja/), which means they can contain Python code. They run inside a sandboxed environment which has access to the following variables: + +- `preset` - the current preset object +- `config` - the current config (an alias for `preset.config`) +- `warn(message)` - display a custom warning message to the user +- `abort(message)` - abort the scan with an optional message + +If you aren't able to accomplish what you want with conditions, or if you need access to a new variable/function, please let us know on [Github](https://github.com/blacklanternsecurity/bbot/issues/new/choose). diff --git a/docs/scanning/presets_list.md b/docs/scanning/presets_list.md new file mode 100644 index 000000000..741df38e4 --- /dev/null +++ b/docs/scanning/presets_list.md @@ -0,0 +1,391 @@ +Below is a list of every default BBOT preset, including its YAML. + + +## **cloud-enum** + +Enumerate cloud resources such as storage buckets, etc. + +??? note "`cloud-enum.yml`" + ```yaml title="~/.bbot/presets/cloud-enum.yml" + description: Enumerate cloud resources such as storage buckets, etc. + + include: + - subdomain-enum + + flags: + - cloud-enum + ``` + + + +Modules: [54]("`anubisdb`, `asn`, `azure_realm`, `azure_tenant`, `baddns_zone`, `baddns`, `bevigil`, `binaryedge`, `bucket_amazon`, `bucket_azure`, `bucket_digitalocean`, `bucket_file_enum`, `bucket_firebase`, `bucket_google`, `builtwith`, `c99`, `censys`, `certspotter`, `chaos`, `columbus`, `crt`, `digitorus`, `dnsbrute_mutations`, `dnsbrute`, `dnscaa`, `dnscommonsrv`, `dnsdumpster`, `fullhunt`, `github_codesearch`, `github_org`, `hackertarget`, `httpx`, `hunterio`, `internetdb`, `ipneighbor`, `leakix`, `myssl`, `oauth`, `otx`, `passivetotal`, `postman`, `rapiddns`, `riddler`, `securitytrails`, `shodan_dns`, `sitedossier`, `social`, `sslcert`, `subdomaincenter`, `threatminer`, `urlscan`, `virustotal`, `wayback`, `zoomeye`") + +## **code-enum** + +Enumerate Git repositories, Docker images, etc. + +??? note "`code-enum.yml`" + ```yaml title="~/.bbot/presets/code-enum.yml" + description: Enumerate Git repositories, Docker images, etc. + + flags: + - code-enum + ``` + + + +Modules: [10]("`code_repository`, `dockerhub`, `git`, `github_codesearch`, `github_org`, `gitlab`, `httpx`, `postman`, `social`, `trufflehog`") + +## **dirbust-heavy** + +Recursive web directory brute-force (aggressive) + +??? note "`dirbust-heavy.yml`" + ```yaml title="~/.bbot/presets/web/dirbust-heavy.yml" + description: Recursive web directory brute-force (aggressive) + + include: + - spider + + flags: + - iis-shortnames + + modules: + - ffuf + - wayback + + config: + modules: + iis_shortnames: + # we exploit the shortnames vulnerability to produce URL_HINTs which are consumed by ffuf_shortnames + detect_only: False + ffuf: + depth: 3 + lines: 5000 + extensions: + - php + - asp + - aspx + - ashx + - asmx + - jsp + - jspx + - cfm + - zip + - conf + - config + - xml + - json + - yml + - yaml + # emit URLs from wayback + wayback: + urls: True + ``` + +Category: web + +Modules: [5]("`ffuf_shortnames`, `ffuf`, `httpx`, `iis_shortnames`, `wayback`") + +## **dirbust-light** + +Basic web directory brute-force (surface-level directories only) + +??? note "`dirbust-light.yml`" + ```yaml title="~/.bbot/presets/web/dirbust-light.yml" + description: Basic web directory brute-force (surface-level directories only) + + include: + - iis-shortnames + + modules: + - ffuf + + config: + modules: + ffuf: + # wordlist size = 1000 + lines: 1000 + ``` + +Category: web + +Modules: [4]("`ffuf_shortnames`, `ffuf`, `httpx`, `iis_shortnames`") + +## **dotnet-audit** + +Comprehensive scan for all IIS/.NET specific modules and module settings + +??? note "`dotnet-audit.yml`" + ```yaml title="~/.bbot/presets/web/dotnet-audit.yml" + description: Comprehensive scan for all IIS/.NET specific modules and module settings + + + include: + - iis-shortnames + + modules: + - httpx + - badsecrets + - ffuf_shortnames + - ffuf + - telerik + - ajaxpro + - dotnetnuke + + config: + modules: + ffuf: + extensions: asp,aspx,ashx,asmx,ascx + telerik: + exploit_RAU_crypto: True + + ``` + +Category: web + +Modules: [8]("`ajaxpro`, `badsecrets`, `dotnetnuke`, `ffuf_shortnames`, `ffuf`, `httpx`, `iis_shortnames`, `telerik`") + +## **email-enum** + +Enumerate email addresses from APIs, web crawling, etc. + +??? note "`email-enum.yml`" + ```yaml title="~/.bbot/presets/email-enum.yml" + description: Enumerate email addresses from APIs, web crawling, etc. + + flags: + - email-enum + + output_modules: + - emails + ``` + + + +Modules: [7]("`dehashed`, `dnscaa`, `emailformat`, `hunterio`, `pgp`, `skymem`, `sslcert`") + +## **iis-shortnames** + +Recursively enumerate IIS shortnames + +??? note "`iis-shortnames.yml`" + ```yaml title="~/.bbot/presets/web/iis-shortnames.yml" + description: Recursively enumerate IIS shortnames + + flags: + - iis-shortnames + + config: + modules: + iis_shortnames: + # exploit the vulnerability + detect_only: false + ``` + +Category: web + +Modules: [3]("`ffuf_shortnames`, `httpx`, `iis_shortnames`") + +## **kitchen-sink** + +Everything everywhere all at once + +??? note "`kitchen-sink.yml`" + ```yaml title="~/.bbot/presets/kitchen-sink.yml" + description: Everything everywhere all at once + + include: + - subdomain-enum + - cloud-enum + - code-enum + - email-enum + - spider + - web-basic + - paramminer + - dirbust-light + - web-screenshots + + config: + modules: + baddns: + enable_references: True + + + ``` + + + +Modules: [76]("`anubisdb`, `asn`, `azure_realm`, `azure_tenant`, `baddns_zone`, `baddns`, `badsecrets`, `bevigil`, `binaryedge`, `bucket_amazon`, `bucket_azure`, `bucket_digitalocean`, `bucket_file_enum`, `bucket_firebase`, `bucket_google`, `builtwith`, `c99`, `censys`, `certspotter`, `chaos`, `code_repository`, `columbus`, `crt`, `dehashed`, `digitorus`, `dnsbrute_mutations`, `dnsbrute`, `dnscaa`, `dnscommonsrv`, `dnsdumpster`, `dockerhub`, `emailformat`, `ffuf_shortnames`, `ffuf`, `filedownload`, `fullhunt`, `git`, `github_codesearch`, `github_org`, `gitlab`, `gowitness`, `hackertarget`, `httpx`, `hunterio`, `iis_shortnames`, `internetdb`, `ipneighbor`, `leakix`, `myssl`, `ntlm`, `oauth`, `otx`, `paramminer_cookies`, `paramminer_getparams`, `paramminer_headers`, `passivetotal`, `pgp`, `postman`, `rapiddns`, `riddler`, `robots`, `secretsdb`, `securitytrails`, `shodan_dns`, `sitedossier`, `skymem`, `social`, `sslcert`, `subdomaincenter`, `threatminer`, `trufflehog`, `urlscan`, `virustotal`, `wappalyzer`, `wayback`, `zoomeye`") + +## **paramminer** + +Discover new web parameters via brute-force + +??? note "`paramminer.yml`" + ```yaml title="~/.bbot/presets/web/paramminer.yml" + description: Discover new web parameters via brute-force + + flags: + - web-paramminer + + modules: + - httpx + + config: + web: + spider_distance: 1 + spider_depth: 4 + ``` + +Category: web + +Modules: [4]("`httpx`, `paramminer_cookies`, `paramminer_getparams`, `paramminer_headers`") + +## **spider** + +Recursive web spider + +??? note "`spider.yml`" + ```yaml title="~/.bbot/presets/spider.yml" + description: Recursive web spider + + modules: + - httpx + + config: + web: + # how many links to follow in a row + spider_distance: 2 + # don't follow links whose directory depth is higher than 4 + spider_depth: 4 + # maximum number of links to follow per page + spider_links_per_page: 25 + ``` + + + +Modules: [1]("`httpx`") + +## **subdomain-enum** + +Enumerate subdomains via APIs, brute-force + +??? note "`subdomain-enum.yml`" + ```yaml title="~/.bbot/presets/subdomain-enum.yml" + description: Enumerate subdomains via APIs, brute-force + + flags: + # enable every module with the subdomain-enum flag + - subdomain-enum + + output_modules: + # output unique subdomains to TXT file + - subdomains + + config: + dns: + threads: 25 + brute_threads: 1000 + # put your API keys here + modules: + github: + api_key: "" + chaos: + api_key: "" + securitytrails: + api_key: "" + ``` + + + +Modules: [47]("`anubisdb`, `asn`, `azure_realm`, `azure_tenant`, `baddns_zone`, `bevigil`, `binaryedge`, `builtwith`, `c99`, `censys`, `certspotter`, `chaos`, `columbus`, `crt`, `digitorus`, `dnsbrute_mutations`, `dnsbrute`, `dnscaa`, `dnscommonsrv`, `dnsdumpster`, `fullhunt`, `github_codesearch`, `github_org`, `hackertarget`, `httpx`, `hunterio`, `internetdb`, `ipneighbor`, `leakix`, `myssl`, `oauth`, `otx`, `passivetotal`, `postman`, `rapiddns`, `riddler`, `securitytrails`, `shodan_dns`, `sitedossier`, `social`, `sslcert`, `subdomaincenter`, `threatminer`, `urlscan`, `virustotal`, `wayback`, `zoomeye`") + +## **web-basic** + +Quick web scan + +??? note "`web-basic.yml`" + ```yaml title="~/.bbot/presets/web-basic.yml" + description: Quick web scan + + include: + - iis-shortnames + + flags: + - web-basic + ``` + + + +Modules: [18]("`azure_realm`, `baddns`, `badsecrets`, `bucket_amazon`, `bucket_azure`, `bucket_firebase`, `bucket_google`, `ffuf_shortnames`, `filedownload`, `git`, `httpx`, `iis_shortnames`, `ntlm`, `oauth`, `robots`, `secretsdb`, `sslcert`, `wappalyzer`") + +## **web-screenshots** + +Take screenshots of webpages + +??? note "`web-screenshots.yml`" + ```yaml title="~/.bbot/presets/web-screenshots.yml" + description: Take screenshots of webpages + + flags: + - web-screenshots + + config: + modules: + gowitness: + resolution_x: 1440 + resolution_y: 900 + # folder to output web screenshots (default is inside ~/.bbot/scans/scan_name) + output_path: "" + # whether to take screenshots of social media pages + social: True + ``` + + + +Modules: [3]("`gowitness`, `httpx`, `social`") + +## **web-thorough** + +Aggressive web scan + +??? note "`web-thorough.yml`" + ```yaml title="~/.bbot/presets/web-thorough.yml" + description: Aggressive web scan + + include: + # include the web-basic preset + - web-basic + + flags: + - web-thorough + ``` + + + +Modules: [29]("`ajaxpro`, `azure_realm`, `baddns`, `badsecrets`, `bucket_amazon`, `bucket_azure`, `bucket_digitalocean`, `bucket_firebase`, `bucket_google`, `bypass403`, `dastardly`, `dotnetnuke`, `ffuf_shortnames`, `filedownload`, `generic_ssrf`, `git`, `host_header`, `httpx`, `hunt`, `iis_shortnames`, `ntlm`, `oauth`, `robots`, `secretsdb`, `smuggler`, `sslcert`, `telerik`, `url_manipulation`, `wappalyzer`") + + +## Table of Default Presets + +Here is a the same data, but in a table: + + +| Preset | Category | Description | # Modules | Modules | +|-----------------|------------|--------------------------------------------------------------------------|-------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| cloud-enum | | Enumerate cloud resources such as storage buckets, etc. | 54 | anubisdb, asn, azure_realm, azure_tenant, baddns, baddns_zone, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, columbus, crt, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, internetdb, ipneighbor, leakix, myssl, oauth, otx, passivetotal, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, social, sslcert, subdomaincenter, threatminer, urlscan, virustotal, wayback, zoomeye | +| code-enum | | Enumerate Git repositories, Docker images, etc. | 10 | code_repository, dockerhub, git, github_codesearch, github_org, gitlab, httpx, postman, social, trufflehog | +| dirbust-heavy | web | Recursive web directory brute-force (aggressive) | 5 | ffuf, ffuf_shortnames, httpx, iis_shortnames, wayback | +| dirbust-light | web | Basic web directory brute-force (surface-level directories only) | 4 | ffuf, ffuf_shortnames, httpx, iis_shortnames | +| dotnet-audit | web | Comprehensive scan for all IIS/.NET specific modules and module settings | 8 | ajaxpro, badsecrets, dotnetnuke, ffuf, ffuf_shortnames, httpx, iis_shortnames, telerik | +| email-enum | | Enumerate email addresses from APIs, web crawling, etc. | 7 | dehashed, dnscaa, emailformat, hunterio, pgp, skymem, sslcert | +| iis-shortnames | web | Recursively enumerate IIS shortnames | 3 | ffuf_shortnames, httpx, iis_shortnames | +| kitchen-sink | | Everything everywhere all at once | 76 | anubisdb, asn, azure_realm, azure_tenant, baddns, baddns_zone, badsecrets, bevigil, binaryedge, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_file_enum, bucket_firebase, bucket_google, builtwith, c99, censys, certspotter, chaos, code_repository, columbus, crt, dehashed, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, dockerhub, emailformat, ffuf, ffuf_shortnames, filedownload, fullhunt, git, github_codesearch, github_org, gitlab, gowitness, hackertarget, httpx, hunterio, iis_shortnames, internetdb, ipneighbor, leakix, myssl, ntlm, oauth, otx, paramminer_cookies, paramminer_getparams, paramminer_headers, passivetotal, pgp, postman, rapiddns, riddler, robots, secretsdb, securitytrails, shodan_dns, sitedossier, skymem, social, sslcert, subdomaincenter, threatminer, trufflehog, urlscan, virustotal, wappalyzer, wayback, zoomeye | +| paramminer | web | Discover new web parameters via brute-force | 4 | httpx, paramminer_cookies, paramminer_getparams, paramminer_headers | +| spider | | Recursive web spider | 1 | httpx | +| subdomain-enum | | Enumerate subdomains via APIs, brute-force | 47 | anubisdb, asn, azure_realm, azure_tenant, baddns_zone, bevigil, binaryedge, builtwith, c99, censys, certspotter, chaos, columbus, crt, digitorus, dnsbrute, dnsbrute_mutations, dnscaa, dnscommonsrv, dnsdumpster, fullhunt, github_codesearch, github_org, hackertarget, httpx, hunterio, internetdb, ipneighbor, leakix, myssl, oauth, otx, passivetotal, postman, rapiddns, riddler, securitytrails, shodan_dns, sitedossier, social, sslcert, subdomaincenter, threatminer, urlscan, virustotal, wayback, zoomeye | +| web-basic | | Quick web scan | 18 | azure_realm, baddns, badsecrets, bucket_amazon, bucket_azure, bucket_firebase, bucket_google, ffuf_shortnames, filedownload, git, httpx, iis_shortnames, ntlm, oauth, robots, secretsdb, sslcert, wappalyzer | +| web-screenshots | | Take screenshots of webpages | 3 | gowitness, httpx, social | +| web-thorough | | Aggressive web scan | 29 | ajaxpro, azure_realm, baddns, badsecrets, bucket_amazon, bucket_azure, bucket_digitalocean, bucket_firebase, bucket_google, bypass403, dastardly, dotnetnuke, ffuf_shortnames, filedownload, generic_ssrf, git, host_header, httpx, hunt, iis_shortnames, ntlm, oauth, robots, secretsdb, smuggler, sslcert, telerik, url_manipulation, wappalyzer | + diff --git a/docs/scanning/tips_and_tricks.md b/docs/scanning/tips_and_tricks.md index c9843a920..72db6dcb1 100644 --- a/docs/scanning/tips_and_tricks.md +++ b/docs/scanning/tips_and_tricks.md @@ -26,41 +26,43 @@ BBOT modules can be parallelized so that more than one instance runs at a time. ```python class baddns(BaseModule): - max_event_handlers = 8 + module_threads = 8 ``` -To change the number of instances, you can set a module's `max_event_handlers` in the config: +To override this, you can set a module's `module_threads` in the config: ```bash -# increase the "baddns" module to 20 concurrent instances -bbot -t evilcorp.com -m baddns -c modules.baddns.max_event_handlers=20 +# increase baddns threads to 20 +bbot -t evilcorp.com -m baddns -c modules.baddns.module_threads=20 ``` -### Boost Massdns Thread Count +### Boost DNS Brute-force Speed If you have a fast internet connection or are running BBOT from a cloud VM, you can speed up subdomain enumeration by cranking the threads for `massdns`. The default is `1000`, which is about 1MB/s of DNS traffic: ```bash # massdns with 5000 resolvers, about 5MB/s -bbot -t evilcorp.com -f subdomain-enum -c modules.massdns.max_resolvers=5000 +bbot -t evilcorp.com -f subdomain-enum -c dns.brute_threads=5000 ``` ### Web Spider -The web spider is great for finding juicy data like subdomains, email addresses, and javascript secrets buried in webpages. However since it can lengthen the duration of a scan, it's disabled by default. To enable the web spider, you must increase the value of `web_spider_distance`. +The web spider is great for finding juicy data like subdomains, email addresses, and javascript secrets buried in webpages. However since it can lengthen the duration of a scan, it's disabled by default. To enable the web spider, you must increase the value of `web.spider_distance`. The web spider is controlled with three config values: -- `web_spider_depth` (default: `1`: the maximum directory depth allowed. This is to prevent the spider from delving too deep into a website. -- `web_spider_distance` (`0` == all spidering disabled, default: `0`): the maximum number of links that can be followed in a row. This is designed to limit the spider in cases where `web_spider_depth` fails (e.g. for an ecommerce website with thousands of base-level URLs). -- `web_spider_links_per_page` (default: `25`): the maximum number of links per page that can be followed. This is designed to save you in cases where a single page has hundreds or thousands of links. +- `web.spider_depth` (default: `1`: the maximum directory depth allowed. This is to prevent the spider from delving too deep into a website. +- `web.spider_distance` (`0` == all spidering disabled, default: `0`): the maximum number of links that can be followed in a row. This is designed to limit the spider in cases where `web.spider_depth` fails (e.g. for an ecommerce website with thousands of base-level URLs). +- `web.spider_links_per_page` (default: `25`): the maximum number of links per page that can be followed. This is designed to save you in cases where a single page has hundreds or thousands of links. Here is a typical example: ```yaml title="spider.yml" -web_spider_depth: 2 -web_spider_distance: 2 -web_spider_links_per_page: 25 +config: + web: + spider_depth: 2 + spider_distance: 2 + spider_links_per_page: 25 ``` ```bash @@ -80,7 +82,7 @@ bbot -t evilcorp.com -f subdomain-enum -c spider.yml If your goal is to feed BBOT data into a SIEM such as Elastic, be sure to enable this option when scanning: ```bash -bbot -t evilcorp.com -c output_modules.json.siem_friendly=true +bbot -t evilcorp.com -c modules.json.siem_friendly=true ``` This nests the event's `.data` beneath its event type like so: @@ -113,17 +115,26 @@ omit_event_types: ``` ### Display Out-of-scope Events -By default, BBOT only shows in-scope events (with a few exceptions for things like storage buckets). If you want to see events that BBOT is emitting internally (such as for DNS resolution, etc.), you can increase `scope_report_distance` in the config or on the command line like so: +By default, BBOT only shows in-scope events (with a few exceptions for things like storage buckets). If you want to see events that BBOT is emitting internally (such as for DNS resolution, etc.), you can increase `scope.report_distance` in the config or on the command line like so: ~~~bash # display events up to scope distance 2 (default == 0) -bbot -f subdomain-enum -t evilcorp.com -c scope_report_distance=2 +bbot -f subdomain-enum -t evilcorp.com -c scope.report_distance=2 ~~~ ### Speed Up Scans By Disabling DNS Resolution -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_resolution` to `false`. + +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`: + +~~~bash +# completely disable DNS resolution +bbot -m httpx gowitness wappalyzer -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: + ~~~bash -# disable the creation of new events from DNS resoluion -bbot -m httpx gowitness wappalyzer -t urls.txt -c dns_resolution=false +# only resolve A and AAAA records +bbot -m httpx gowitness wappalyzer -t urls.txt -c dns.minimal=true ~~~ ## FAQ @@ -134,9 +145,9 @@ bbot -m httpx gowitness wappalyzer -t urls.txt -c dns_resolution=false For example, when [`excavate`](index.md/#types-of-modules) gets an `HTTP_RESPONSE` event, it extracts links from the raw HTTP response as `URL_UNVERIFIED`s and then passes them back to `httpx` to be visited. -By default, `URL_UNVERIFIED`s are hidden from output. If you want to see all of them including the out-of-scope ones, you can do it by changing `omit_event_types` and `scope_report_distance` in the config like so: +By default, `URL_UNVERIFIED`s are hidden from output. If you want to see all of them including the out-of-scope ones, you can do it by changing `omit_event_types` and `scope.report_distance` in the config like so: ```bash # visit www.evilcorp.com and extract all the links -bbot -t www.evilcorp.com -m httpx -c omit_event_types=[] scope_report_distance=2 +bbot -t www.evilcorp.com -m httpx -c omit_event_types=[] scope.report_distance=2 ``` diff --git a/examples/discord_bot.py b/examples/discord_bot.py new file mode 100644 index 000000000..f435b0301 --- /dev/null +++ b/examples/discord_bot.py @@ -0,0 +1,71 @@ +import discord +from discord.ext import commands + +from bbot.scanner import Scanner +from bbot.modules.output.discord import Discord + + +class BBOTDiscordBot(commands.Cog): + """ + A simple Discord bot capable of running a BBOT scan. + + To set up: + 1. Go to Discord Developer Portal (https://discord.com/developers) + 2. Create a new application + 3. Create an invite link for the bot, visit the link to invite it to your server + - Your Application --> OAuth2 --> URL Generator + - For Scopes, select "bot"" + - For Bot Permissions, select: + - Read Messages/View Channels + - Send Messages + 4. Turn on "Message Content Intent" + - Your Application --> Bot --> Privileged Gateway Intents --> Message Content Intent + 5. Copy your Discord Bot Token and put it at the top this file + - Your Application --> Bot --> Reset Token + 6. Run this script + + To scan evilcorp.com, you would type: + + /scan evilcorp.com + + Results will be output to the same channel. + """ + + def __init__(self): + self.current_scan = None + + @commands.command(name="scan", description="Scan a target with BBOT.") + async def scan(self, ctx, target: str): + if self.current_scan is not None: + self.current_scan.stop() + await ctx.send(f"Starting scan against {target}.") + + # creates scan instance + self.current_scan = Scanner(target, flags="subdomain-enum") + discord_module = Discord(self.current_scan) + + seen = set() + num_events = 0 + # start scan and iterate through results + async for event in self.current_scan.async_start(): + if hash(event) in seen: + continue + seen.add(hash(event)) + await ctx.send(discord_module.format_message(event)) + num_events += 1 + + await ctx.send(f"Finished scan against {target}. {num_events:,} results.") + self.current_scan = None + + +if __name__ == "__main__": + intents = discord.Intents.default() + intents.message_content = True + bot = commands.Bot(command_prefix="/", intents=intents) + + @bot.event + async def on_ready(): + print(f"We have logged in as {bot.user}") + await bot.add_cog(BBOTDiscordBot()) + + bot.run("DISCORD_BOT_TOKEN_HERE") diff --git a/mkdocs.yml b/mkdocs.yml index d7bb10118..c154fb87f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,9 @@ nav: - Comparison to Other Tools: comparison.md - Scanning: - Scanning Overview: scanning/index.md + - Presets: + - Overview: scanning/presets.md + - List of Presets: scanning/presets_list.md - Events: scanning/events.md - Output: scanning/output.md - Tips and Tricks: scanning/tips_and_tricks.md @@ -30,23 +33,31 @@ nav: - List of Modules: modules/list_of_modules.md - Nuclei: modules/nuclei.md - Misc: + - Contribution: contribution.md - Release History: release_history.md - Troubleshooting: troubleshooting.md - Developer Manual: - - How to Write a Module: contribution.md - Development Overview: dev/index.md - - Scanner: dev/scanner.md - - Event: dev/event.md - - Target: dev/target.md - - BaseModule: dev/basemodule.md - - Helpers: - - Overview: dev/helpers/index.md - - Command: dev/helpers/command.md - - DNS: dev/helpers/dns.md - - Interactsh: dev/helpers/interactsh.md - - Miscellaneous: dev/helpers/misc.md - - Web: dev/helpers/web.md - - Word Cloud: dev/helpers/wordcloud.md + - BBOT Internal Architecture: dev/architecture.md + - How to Write a BBOT Module: dev/module_howto.md + - Unit Tests: dev/tests.md + - Discord Bot Example: dev/discord_bot.md + - Code Reference: + - Scanner: dev/scanner.md + - Presets: dev/presets.md + - Event: dev/event.md + - Target: dev/target.md + - BaseModule: dev/basemodule.md + - BBOTCore: dev/core.md + - Engine: dev/engine.md + - Helpers: + - Overview: dev/helpers/index.md + - Command: dev/helpers/command.md + - DNS: dev/helpers/dns.md + - Interactsh: dev/helpers/interactsh.md + - Miscellaneous: dev/helpers/misc.md + - Web: dev/helpers/web.md + - Word Cloud: dev/helpers/wordcloud.md theme: name: material @@ -54,6 +65,7 @@ theme: favicon: favicon.png features: - content.code.copy + - content.tooltips - navigation.tabs - navigation.sections - navigation.expand @@ -89,6 +101,7 @@ markdown_extensions: - attr_list - admonition - pymdownx.details + - pymdownx.snippets - pymdownx.superfences - pymdownx.highlight: use_pygments: True @@ -101,5 +114,7 @@ markdown_extensions: format: !!python/name:pymdownx.superfences.fence_code_format extra_javascript: - - https://unpkg.com/tablesort@5.3.0/dist/tablesort.min.js - javascripts/tablesort.js + - javascripts/vega@5.js + - javascripts/vega-lite@5.js + - javascripts/vega-embed@6.js diff --git a/poetry.lock b/poetry.lock index 1c3bc8efc..f8ffb8f86 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,14 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" -version = "0.6.0" +version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" files = [ - {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, - {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, + {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]] @@ -27,13 +27,13 @@ ansible-core = ">=2.15.7,<2.16.0" [[package]] name = "ansible-core" -version = "2.15.10" +version = "2.15.12" description = "Radically simple IT automation" optional = false python-versions = ">=3.9" files = [ - {file = "ansible-core-2.15.10.tar.gz", hash = "sha256:954dbe8e4e802a4dd5df0366193975b692a05806aa8d7358418a7e617346b20f"}, - {file = "ansible_core-2.15.10-py3-none-any.whl", hash = "sha256:42e49f1a6d8cf6cccde775c06c1394885353b71ad9e5f670c6f32d2890127ce8"}, + {file = "ansible_core-2.15.12-py3-none-any.whl", hash = "sha256:390edd603420122f7cb1c470d8d1f8bdbbd795a1844dd03c1917db21935aecb9"}, + {file = "ansible_core-2.15.12.tar.gz", hash = "sha256:5fde82cd3928d9857ad880782c644f27d3168b0f25321d5a8d6befa524aa1818"}, ] [package.dependencies] @@ -74,13 +74,13 @@ files = [ [[package]] name = "anyio" -version = "4.3.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, - {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -96,13 +96,13 @@ trio = ["trio (>=0.23)"] [[package]] name = "babel" -version = "2.14.0" +version = "2.15.0" description = "Internationalization utilities" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, - {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, + {file = "Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb"}, + {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, ] [package.extras] @@ -131,33 +131,33 @@ lxml = ["lxml"] [[package]] name = "black" -version = "24.4.0" +version = "24.4.2" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ad001a9ddd9b8dfd1b434d566be39b1cd502802c8d38bbb1ba612afda2ef436"}, - {file = "black-24.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3a3a092b8b756c643fe45f4624dbd5a389f770a4ac294cf4d0fce6af86addaf"}, - {file = "black-24.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae79397f367ac8d7adb6c779813328f6d690943f64b32983e896bcccd18cbad"}, - {file = "black-24.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:71d998b73c957444fb7c52096c3843875f4b6b47a54972598741fe9a7f737fcb"}, - {file = "black-24.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5537f456a22cf5cfcb2707803431d2feeb82ab3748ade280d6ccd0b40ed2e8"}, - {file = "black-24.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64e60a7edd71fd542a10a9643bf369bfd2644de95ec71e86790b063aa02ff745"}, - {file = "black-24.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd5b4f76056cecce3e69b0d4c228326d2595f506797f40b9233424e2524c070"}, - {file = "black-24.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:64578cf99b6b46a6301bc28bdb89f9d6f9b592b1c5837818a177c98525dbe397"}, - {file = "black-24.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f95cece33329dc4aa3b0e1a771c41075812e46cf3d6e3f1dfe3d91ff09826ed2"}, - {file = "black-24.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4396ca365a4310beef84d446ca5016f671b10f07abdba3e4e4304218d2c71d33"}, - {file = "black-24.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d99dfdf37a2a00a6f7a8dcbd19edf361d056ee51093b2445de7ca09adac965"}, - {file = "black-24.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:21f9407063ec71c5580b8ad975653c66508d6a9f57bd008bb8691d273705adcd"}, - {file = "black-24.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:652e55bb722ca026299eb74e53880ee2315b181dfdd44dca98e43448620ddec1"}, - {file = "black-24.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f2966b9b2b3b7104fca9d75b2ee856fe3fdd7ed9e47c753a4bb1a675f2caab8"}, - {file = "black-24.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bb9ca06e556a09f7f7177bc7cb604e5ed2d2df1e9119e4f7d2f1f7071c32e5d"}, - {file = "black-24.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4e71cdebdc8efeb6deaf5f2deb28325f8614d48426bed118ecc2dcaefb9ebf3"}, - {file = "black-24.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6644f97a7ef6f401a150cca551a1ff97e03c25d8519ee0bbc9b0058772882665"}, - {file = "black-24.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75a2d0b4f5eb81f7eebc31f788f9830a6ce10a68c91fbe0fade34fff7a2836e6"}, - {file = "black-24.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb949f56a63c5e134dfdca12091e98ffb5fd446293ebae123d10fc1abad00b9e"}, - {file = "black-24.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:7852b05d02b5b9a8c893ab95863ef8986e4dda29af80bbbda94d7aee1abf8702"}, - {file = "black-24.4.0-py3-none-any.whl", hash = "sha256:74eb9b5420e26b42c00a3ff470dc0cd144b80a766128b1771d07643165e08d0e"}, - {file = "black-24.4.0.tar.gz", hash = "sha256:f07b69fda20578367eaebbd670ff8fc653ab181e1ff95d84497f9fa20e7d0641"}, + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, ] [package.dependencies] @@ -188,13 +188,13 @@ files = [ [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.6.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, + {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, ] [[package]] @@ -387,18 +387,20 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "cloudcheck" -version = "3.1.0.318" +version = "5.0.1.415" description = "Check whether an IP address belongs to a cloud provider" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "cloudcheck-3.1.0.318-py3-none-any.whl", hash = "sha256:471dba97531e1f60aadab8daa6cb1d63727f67c16fd7b4758db46c9af2f362f1"}, - {file = "cloudcheck-3.1.0.318.tar.gz", hash = "sha256:ba7fcc026817aa05f74c7789d2ac306469f3143f91b3ea9f95c57c70a7b0b787"}, + {file = "cloudcheck-5.0.1.415-py3-none-any.whl", hash = "sha256:e5f728106ddc2cdf43ee5a654d6ec069572ea925d30daec913c9a5a07209a52e"}, + {file = "cloudcheck-5.0.1.415.tar.gz", hash = "sha256:ef3f7351dde77c298d46d48dd69919c6c6d2563aeece46aa35ecd5281cbff0dd"}, ] [package.dependencies] httpx = ">=0.26,<0.28" pydantic = ">=2.4.2,<3.0.0" +radixtarget = ">=1.0.0.14,<2.0.0.0" +regex = ">=2024.4.16,<2025.0.0" [[package]] name = "colorama" @@ -413,63 +415,63 @@ files = [ [[package]] name = "coverage" -version = "7.4.4" +version = "7.5.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, - {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, - {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, - {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, - {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, - {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, - {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, - {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, - {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, - {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, - {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, - {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, - {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, - {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6519d917abb15e12380406d721e37613e2a67d166f9fb7e5a8ce0375744cd45"}, + {file = "coverage-7.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aea7da970f1feccf48be7335f8b2ca64baf9b589d79e05b9397a06696ce1a1ec"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:923b7b1c717bd0f0f92d862d1ff51d9b2b55dbbd133e05680204465f454bb286"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62bda40da1e68898186f274f832ef3e759ce929da9a9fd9fcf265956de269dbc"}, + {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8b7339180d00de83e930358223c617cc343dd08e1aa5ec7b06c3a121aec4e1d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25a5caf742c6195e08002d3b6c2dd6947e50efc5fc2c2205f61ecb47592d2d83"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:05ac5f60faa0c704c0f7e6a5cbfd6f02101ed05e0aee4d2822637a9e672c998d"}, + {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:239a4e75e09c2b12ea478d28815acf83334d32e722e7433471fbf641c606344c"}, + {file = "coverage-7.5.3-cp310-cp310-win32.whl", hash = "sha256:a5812840d1d00eafae6585aba38021f90a705a25b8216ec7f66aebe5b619fb84"}, + {file = "coverage-7.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:33ca90a0eb29225f195e30684ba4a6db05dbef03c2ccd50b9077714c48153cac"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81bc26d609bf0fbc622c7122ba6307993c83c795d2d6f6f6fd8c000a770d974"}, + {file = "coverage-7.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cec2af81f9e7569280822be68bd57e51b86d42e59ea30d10ebdbb22d2cb7232"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55f689f846661e3f26efa535071775d0483388a1ccfab899df72924805e9e7cd"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50084d3516aa263791198913a17354bd1dc627d3c1639209640b9cac3fef5807"}, + {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341dd8f61c26337c37988345ca5c8ccabeff33093a26953a1ac72e7d0103c4fb"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab0b028165eea880af12f66086694768f2c3139b2c31ad5e032c8edbafca6ffc"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5bc5a8c87714b0c67cfeb4c7caa82b2d71e8864d1a46aa990b5588fa953673b8"}, + {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:38a3b98dae8a7c9057bd91fbf3415c05e700a5114c5f1b5b0ea5f8f429ba6614"}, + {file = "coverage-7.5.3-cp311-cp311-win32.whl", hash = "sha256:fcf7d1d6f5da887ca04302db8e0e0cf56ce9a5e05f202720e49b3e8157ddb9a9"}, + {file = "coverage-7.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c836309931839cca658a78a888dab9676b5c988d0dd34ca247f5f3e679f4e7a"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:296a7d9bbc598e8744c00f7a6cecf1da9b30ae9ad51c566291ff1314e6cbbed8"}, + {file = "coverage-7.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34d6d21d8795a97b14d503dcaf74226ae51eb1f2bd41015d3ef332a24d0a17b3"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e317953bb4c074c06c798a11dbdd2cf9979dbcaa8ccc0fa4701d80042d4ebf1"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:705f3d7c2b098c40f5b81790a5fedb274113373d4d1a69e65f8b68b0cc26f6db"}, + {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1196e13c45e327d6cd0b6e471530a1882f1017eb83c6229fc613cd1a11b53cd"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:015eddc5ccd5364dcb902eaecf9515636806fa1e0d5bef5769d06d0f31b54523"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd27d8b49e574e50caa65196d908f80e4dff64d7e592d0c59788b45aad7e8b35"}, + {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:33fc65740267222fc02975c061eb7167185fef4cc8f2770267ee8bf7d6a42f84"}, + {file = "coverage-7.5.3-cp312-cp312-win32.whl", hash = "sha256:7b2a19e13dfb5c8e145c7a6ea959485ee8e2204699903c88c7d25283584bfc08"}, + {file = "coverage-7.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0bbddc54bbacfc09b3edaec644d4ac90c08ee8ed4844b0f86227dcda2d428fcb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f78300789a708ac1f17e134593f577407d52d0417305435b134805c4fb135adb"}, + {file = "coverage-7.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b368e1aee1b9b75757942d44d7598dcd22a9dbb126affcbba82d15917f0cc155"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f836c174c3a7f639bded48ec913f348c4761cbf49de4a20a956d3431a7c9cb24"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:244f509f126dc71369393ce5fea17c0592c40ee44e607b6d855e9c4ac57aac98"}, + {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c2872b3c91f9baa836147ca33650dc5c172e9273c808c3c3199c75490e709d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd4b3355b01273a56b20c219e74e7549e14370b31a4ffe42706a8cda91f19f6d"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f542287b1489c7a860d43a7d8883e27ca62ab84ca53c965d11dac1d3a1fab7ce"}, + {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75e3f4e86804023e991096b29e147e635f5e2568f77883a1e6eed74512659ab0"}, + {file = "coverage-7.5.3-cp38-cp38-win32.whl", hash = "sha256:c59d2ad092dc0551d9f79d9d44d005c945ba95832a6798f98f9216ede3d5f485"}, + {file = "coverage-7.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:fa21a04112c59ad54f69d80e376f7f9d0f5f9123ab87ecd18fbb9ec3a2beed56"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5102a92855d518b0996eb197772f5ac2a527c0ec617124ad5242a3af5e25f85"}, + {file = "coverage-7.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1da0a2e3b37b745a2b2a678a4c796462cf753aebf94edcc87dcc6b8641eae31"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8383a6c8cefba1b7cecc0149415046b6fc38836295bc4c84e820872eb5478b3d"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aad68c3f2566dfae84bf46295a79e79d904e1c21ccfc66de88cd446f8686341"}, + {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e079c9ec772fedbade9d7ebc36202a1d9ef7291bc9b3a024ca395c4d52853d7"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bde997cac85fcac227b27d4fb2c7608a2c5f6558469b0eb704c5726ae49e1c52"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:990fb20b32990b2ce2c5f974c3e738c9358b2735bc05075d50a6f36721b8f303"}, + {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3d5a67f0da401e105753d474369ab034c7bae51a4c31c77d94030d59e41df5bd"}, + {file = "coverage-7.5.3-cp39-cp39-win32.whl", hash = "sha256:e08c470c2eb01977d221fd87495b44867a56d4d594f43739a8028f8646a51e0d"}, + {file = "coverage-7.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:1d2a830ade66d3563bb61d1e3c77c8def97b30ed91e166c67d0632c018f380f0"}, + {file = "coverage-7.5.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:3538d8fb1ee9bdd2e2692b3b18c22bb1c19ffbefd06880f5ac496e42d7bb3884"}, + {file = "coverage-7.5.3.tar.gz", hash = "sha256:04aefca5190d1dc7a53a4c1a5a7f8568811306d7a8ee231c42fb69215571944f"}, ] [package.dependencies] @@ -480,43 +482,43 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "42.0.5" +version = "42.0.8" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, - {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, - {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, - {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, - {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, - {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, - {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, - {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, - {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, - {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, - {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, - {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, - {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, - {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, + {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, + {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, + {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, + {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, + {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, + {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, + {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, + {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, + {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, + {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, + {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, + {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, + {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, ] [package.dependencies] @@ -583,24 +585,24 @@ wmi = ["wmi (>=1.5.1)"] [[package]] name = "docutils" -version = "0.21.1" +version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" files = [ - {file = "docutils-0.21.1-py3-none-any.whl", hash = "sha256:14c8d34a55b46c88f9f714adb29cefbdd69fb82f3fef825e59c5faab935390d8"}, - {file = "docutils-0.21.1.tar.gz", hash = "sha256:65249d8a5345bc95e0f40f280ba63c98eb24de35c6c8f5b662e3e8948adea83f"}, + {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, + {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] [[package]] name = "dunamai" -version = "1.20.0" +version = "1.21.1" description = "Dynamic version generation" optional = false python-versions = ">=3.5" files = [ - {file = "dunamai-1.20.0-py3-none-any.whl", hash = "sha256:a2185c227351a52a013c7d7a695d3f3cb6625c3eed14a5295adbbcc7e2f7f8d4"}, - {file = "dunamai-1.20.0.tar.gz", hash = "sha256:c3f1ee64a1e6cc9ebc98adafa944efaccd0db32482d2177e59c1ff6bdf23cd70"}, + {file = "dunamai-1.21.1-py3-none-any.whl", hash = "sha256:fe303541463648b8197c495decf62cd8f15234fb6d891a5f295015e452f656c8"}, + {file = "dunamai-1.21.1.tar.gz", hash = "sha256:d7fea28ad2faf20a6ca5ec121e5c68e55eec6b8ada23d9c387e4e7a574cc559f"}, ] [package.dependencies] @@ -608,13 +610,13 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, ] [package.extras] @@ -622,13 +624,13 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.13.4" +version = "3.14.0" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.13.4-py3-none-any.whl", hash = "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f"}, - {file = "filelock-3.13.4.tar.gz", hash = "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"}, + {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, + {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, ] [package.extras] @@ -671,13 +673,13 @@ dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "griffe" -version = "0.45.2" +version = "0.45.3" 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.8" files = [ - {file = "griffe-0.45.2-py3-none-any.whl", hash = "sha256:297ec8530d0c68e5b98ff86fb588ebc3aa3559bb5dc21f3caea8d9542a350133"}, - {file = "griffe-0.45.2.tar.gz", hash = "sha256:83ce7dcaafd8cb7f43cbf1a455155015a1eb624b1ffd93249e5e1c4a22b2fdb2"}, + {file = "griffe-0.45.3-py3-none-any.whl", hash = "sha256:ed1481a680ae3e28f91a06e0d8a51a5c9b97555aa2527abc2664447cc22337d6"}, + {file = "griffe-0.45.3.tar.gz", hash = "sha256:02ee71cc1a5035864b97bd0dbfff65c33f6f2c8854d3bd48a791905c2b8a44b9"}, ] [package.dependencies] @@ -741,13 +743,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "identify" -version = "2.5.35" +version = "2.5.36" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, - {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, + {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, + {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, ] [package.extras] @@ -811,13 +813,13 @@ files = [ [[package]] name = "jinja2" -version = "3.1.3" +version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] [package.dependencies] @@ -1216,13 +1218,13 @@ pyyaml = ">=5.1" [[package]] name = "mkdocs-material" -version = "9.5.25" +version = "9.5.26" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.25-py3-none-any.whl", hash = "sha256:68fdab047a0b9bfbefe79ce267e8a7daaf5128bcf7867065fcd201ee335fece1"}, - {file = "mkdocs_material-9.5.25.tar.gz", hash = "sha256:d0662561efb725b712207e0ee01f035ca15633f29a64628e24f01ec99d7078f4"}, + {file = "mkdocs_material-9.5.26-py3-none-any.whl", hash = "sha256:5d01fb0aa1c7946a1e3ae8689aa2b11a030621ecb54894e35aabb74c21016312"}, + {file = "mkdocs_material-9.5.26.tar.gz", hash = "sha256:56aeb91d94cffa43b6296fa4fbf0eb7c840136e563eecfd12c2d9e92e50ba326"}, ] [package.dependencies] @@ -1297,6 +1299,98 @@ files = [ griffe = ">=0.44" mkdocstrings = ">=0.25" +[[package]] +name = "mmh3" +version = "4.1.0" +description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." +optional = false +python-versions = "*" +files = [ + {file = "mmh3-4.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:be5ac76a8b0cd8095784e51e4c1c9c318c19edcd1709a06eb14979c8d850c31a"}, + {file = "mmh3-4.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98a49121afdfab67cd80e912b36404139d7deceb6773a83620137aaa0da5714c"}, + {file = "mmh3-4.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5259ac0535874366e7d1a5423ef746e0d36a9e3c14509ce6511614bdc5a7ef5b"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5950827ca0453a2be357696da509ab39646044e3fa15cad364eb65d78797437"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dd0f652ae99585b9dd26de458e5f08571522f0402155809fd1dc8852a613a39"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99d25548070942fab1e4a6f04d1626d67e66d0b81ed6571ecfca511f3edf07e6"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53db8d9bad3cb66c8f35cbc894f336273f63489ce4ac416634932e3cbe79eb5b"}, + {file = "mmh3-4.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75da0f615eb55295a437264cc0b736753f830b09d102aa4c2a7d719bc445ec05"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b926b07fd678ea84b3a2afc1fa22ce50aeb627839c44382f3d0291e945621e1a"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c5b053334f9b0af8559d6da9dc72cef0a65b325ebb3e630c680012323c950bb6"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bf33dc43cd6de2cb86e0aa73a1cc6530f557854bbbe5d59f41ef6de2e353d7b"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fa7eacd2b830727ba3dd65a365bed8a5c992ecd0c8348cf39a05cc77d22f4970"}, + {file = "mmh3-4.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:42dfd6742b9e3eec599f85270617debfa0bbb913c545bb980c8a4fa7b2d047da"}, + {file = "mmh3-4.1.0-cp310-cp310-win32.whl", hash = "sha256:2974ad343f0d39dcc88e93ee6afa96cedc35a9883bc067febd7ff736e207fa47"}, + {file = "mmh3-4.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:74699a8984ded645c1a24d6078351a056f5a5f1fe5838870412a68ac5e28d865"}, + {file = "mmh3-4.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f0dc874cedc23d46fc488a987faa6ad08ffa79e44fb08e3cd4d4cf2877c00a00"}, + {file = "mmh3-4.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3280a463855b0eae64b681cd5b9ddd9464b73f81151e87bb7c91a811d25619e6"}, + {file = "mmh3-4.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:97ac57c6c3301769e757d444fa7c973ceb002cb66534b39cbab5e38de61cd896"}, + {file = "mmh3-4.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7b6502cdb4dbd880244818ab363c8770a48cdccecf6d729ade0241b736b5ec0"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52ba2da04671a9621580ddabf72f06f0e72c1c9c3b7b608849b58b11080d8f14"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a5fef4c4ecc782e6e43fbeab09cff1bac82c998a1773d3a5ee6a3605cde343e"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5135358a7e00991f73b88cdc8eda5203bf9de22120d10a834c5761dbeb07dd13"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cff9ae76a54f7c6fe0167c9c4028c12c1f6de52d68a31d11b6790bb2ae685560"}, + {file = "mmh3-4.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f02576a4d106d7830ca90278868bf0983554dd69183b7bbe09f2fcd51cf54f"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:073d57425a23721730d3ff5485e2da489dd3c90b04e86243dd7211f889898106"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:71e32ddec7f573a1a0feb8d2cf2af474c50ec21e7a8263026e8d3b4b629805db"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7cbb20b29d57e76a58b40fd8b13a9130db495a12d678d651b459bf61c0714cea"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:a42ad267e131d7847076bb7e31050f6c4378cd38e8f1bf7a0edd32f30224d5c9"}, + {file = "mmh3-4.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4a013979fc9390abadc445ea2527426a0e7a4495c19b74589204f9b71bcaafeb"}, + {file = "mmh3-4.1.0-cp311-cp311-win32.whl", hash = "sha256:1d3b1cdad7c71b7b88966301789a478af142bddcb3a2bee563f7a7d40519a00f"}, + {file = "mmh3-4.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0dc6dc32eb03727467da8e17deffe004fbb65e8b5ee2b502d36250d7a3f4e2ec"}, + {file = "mmh3-4.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9ae3a5c1b32dda121c7dc26f9597ef7b01b4c56a98319a7fe86c35b8bc459ae6"}, + {file = "mmh3-4.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0033d60c7939168ef65ddc396611077a7268bde024f2c23bdc283a19123f9e9c"}, + {file = "mmh3-4.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d6af3e2287644b2b08b5924ed3a88c97b87b44ad08e79ca9f93d3470a54a41c5"}, + {file = "mmh3-4.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d82eb4defa245e02bb0b0dc4f1e7ee284f8d212633389c91f7fba99ba993f0a2"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba245e94b8d54765e14c2d7b6214e832557e7856d5183bc522e17884cab2f45d"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb04e2feeabaad6231e89cd43b3d01a4403579aa792c9ab6fdeef45cc58d4ec0"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3b1a27def545ce11e36158ba5d5390cdbc300cfe456a942cc89d649cf7e3b2"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce0ab79ff736d7044e5e9b3bfe73958a55f79a4ae672e6213e92492ad5e734d5"}, + {file = "mmh3-4.1.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b02268be6e0a8eeb8a924d7db85f28e47344f35c438c1e149878bb1c47b1cd3"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:deb887f5fcdaf57cf646b1e062d56b06ef2f23421c80885fce18b37143cba828"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99dd564e9e2b512eb117bd0cbf0f79a50c45d961c2a02402787d581cec5448d5"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:08373082dfaa38fe97aa78753d1efd21a1969e51079056ff552e687764eafdfe"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:54b9c6a2ea571b714e4fe28d3e4e2db37abfd03c787a58074ea21ee9a8fd1740"}, + {file = "mmh3-4.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a7b1edf24c69e3513f879722b97ca85e52f9032f24a52284746877f6a7304086"}, + {file = "mmh3-4.1.0-cp312-cp312-win32.whl", hash = "sha256:411da64b951f635e1e2284b71d81a5a83580cea24994b328f8910d40bed67276"}, + {file = "mmh3-4.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bebc3ecb6ba18292e3d40c8712482b4477abd6981c2ebf0e60869bd90f8ac3a9"}, + {file = "mmh3-4.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:168473dd608ade6a8d2ba069600b35199a9af837d96177d3088ca91f2b3798e3"}, + {file = "mmh3-4.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:372f4b7e1dcde175507640679a2a8790185bb71f3640fc28a4690f73da986a3b"}, + {file = "mmh3-4.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:438584b97f6fe13e944faf590c90fc127682b57ae969f73334040d9fa1c7ffa5"}, + {file = "mmh3-4.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6e27931b232fc676675fac8641c6ec6b596daa64d82170e8597f5a5b8bdcd3b6"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:571a92bad859d7b0330e47cfd1850b76c39b615a8d8e7aa5853c1f971fd0c4b1"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a69d6afe3190fa08f9e3a58e5145549f71f1f3fff27bd0800313426929c7068"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afb127be0be946b7630220908dbea0cee0d9d3c583fa9114a07156f98566dc28"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:940d86522f36348ef1a494cbf7248ab3f4a1638b84b59e6c9e90408bd11ad729"}, + {file = "mmh3-4.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3dcccc4935686619a8e3d1f7b6e97e3bd89a4a796247930ee97d35ea1a39341"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01bb9b90d61854dfc2407c5e5192bfb47222d74f29d140cb2dd2a69f2353f7cc"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bcb1b8b951a2c0b0fb8a5426c62a22557e2ffc52539e0a7cc46eb667b5d606a9"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6477a05d5e5ab3168e82e8b106e316210ac954134f46ec529356607900aea82a"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:da5892287e5bea6977364b15712a2573c16d134bc5fdcdd4cf460006cf849278"}, + {file = "mmh3-4.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:99180d7fd2327a6fffbaff270f760576839dc6ee66d045fa3a450f3490fda7f5"}, + {file = "mmh3-4.1.0-cp38-cp38-win32.whl", hash = "sha256:9b0d4f3949913a9f9a8fb1bb4cc6ecd52879730aab5ff8c5a3d8f5b593594b73"}, + {file = "mmh3-4.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:598c352da1d945108aee0c3c3cfdd0e9b3edef74108f53b49d481d3990402169"}, + {file = "mmh3-4.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:475d6d1445dd080f18f0f766277e1237fa2914e5fe3307a3b2a3044f30892103"}, + {file = "mmh3-4.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5ca07c41e6a2880991431ac717c2a049056fff497651a76e26fc22224e8b5732"}, + {file = "mmh3-4.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ebe052fef4bbe30c0548d12ee46d09f1b69035ca5208a7075e55adfe091be44"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaefd42e85afb70f2b855a011f7b4d8a3c7e19c3f2681fa13118e4d8627378c5"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0ae43caae5a47afe1b63a1ae3f0986dde54b5fb2d6c29786adbfb8edc9edfb"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6218666f74c8c013c221e7f5f8a693ac9cf68e5ac9a03f2373b32d77c48904de"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac59294a536ba447b5037f62d8367d7d93b696f80671c2c45645fa9f1109413c"}, + {file = "mmh3-4.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:086844830fcd1e5c84fec7017ea1ee8491487cfc877847d96f86f68881569d2e"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e42b38fad664f56f77f6fbca22d08450f2464baa68acdbf24841bf900eb98e87"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d08b790a63a9a1cde3b5d7d733ed97d4eb884bfbc92f075a091652d6bfd7709a"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:73ea4cc55e8aea28c86799ecacebca09e5f86500414870a8abaedfcbaf74d288"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:f90938ff137130e47bcec8dc1f4ceb02f10178c766e2ef58a9f657ff1f62d124"}, + {file = "mmh3-4.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:aa1f13e94b8631c8cd53259250556edcf1de71738936b60febba95750d9632bd"}, + {file = "mmh3-4.1.0-cp39-cp39-win32.whl", hash = "sha256:a3b680b471c181490cf82da2142029edb4298e1bdfcb67c76922dedef789868d"}, + {file = "mmh3-4.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:fefef92e9c544a8dbc08f77a8d1b6d48006a750c4375bbcd5ff8199d761e263b"}, + {file = "mmh3-4.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:8e2c1f6a2b41723a4f82bd5a762a777836d29d664fc0095f17910bea0adfd4a6"}, + {file = "mmh3-4.1.0.tar.gz", hash = "sha256:a1cf25348b9acd229dda464a094d6170f47d2850a1fcb762a3b6172d2ce6ca4a"}, +] + +[package.extras] +test = ["mypy (>=1.0)", "pytest (>=7.0.0)"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -1310,18 +1404,15 @@ files = [ [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "omegaconf" version = "2.3.0" @@ -1353,13 +1444,13 @@ dev = ["black", "mypy", "pytest"] [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] @@ -1399,18 +1490,19 @@ ptyprocess = ">=0.5" [[package]] name = "platformdirs" -version = "4.2.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, - {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] [[package]] name = "pluggy" @@ -1568,18 +1660,18 @@ files = [ [[package]] name = "pydantic" -version = "2.7.2" +version = "2.7.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.2-py3-none-any.whl", hash = "sha256:834ab954175f94e6e68258537dc49402c4a5e9d0409b9f1b86b7e934a8372de7"}, - {file = "pydantic-2.7.2.tar.gz", hash = "sha256:71b2945998f9c9b7919a45bde9a50397b289937d215ae141c1d0903ba7149fd7"}, + {file = "pydantic-2.7.3-py3-none-any.whl", hash = "sha256:ea91b002777bf643bb20dd717c028ec43216b24a6001a280f83877fd2655d0b4"}, + {file = "pydantic-2.7.3.tar.gz", hash = "sha256:c46c76a40bb1296728d7a8b99aa73dd70a48c3510111ff290034f860c99c419e"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.3" +pydantic-core = "2.18.4" typing-extensions = ">=4.6.1" [package.extras] @@ -1587,90 +1679,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.3" +version = "2.18.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:744697428fcdec6be5670460b578161d1ffe34743a5c15656be7ea82b008197c"}, - {file = "pydantic_core-2.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b40c05ced1ba4218b14986fe6f283d22e1ae2ff4c8e28881a70fb81fbfcda7"}, - {file = "pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a9a75622357076efb6b311983ff190fbfb3c12fc3a853122b34d3d358126c"}, - {file = "pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2e253af04ceaebde8eb201eb3f3e3e7e390f2d275a88300d6a1959d710539e2"}, - {file = "pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:855ec66589c68aa367d989da5c4755bb74ee92ccad4fdb6af942c3612c067e34"}, - {file = "pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d3e42bb54e7e9d72c13ce112e02eb1b3b55681ee948d748842171201a03a98a"}, - {file = "pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6ac9ffccc9d2e69d9fba841441d4259cb668ac180e51b30d3632cd7abca2b9b"}, - {file = "pydantic_core-2.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c56eca1686539fa0c9bda992e7bd6a37583f20083c37590413381acfc5f192d6"}, - {file = "pydantic_core-2.18.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:17954d784bf8abfc0ec2a633108207ebc4fa2df1a0e4c0c3ccbaa9bb01d2c426"}, - {file = "pydantic_core-2.18.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:98ed737567d8f2ecd54f7c8d4f8572ca7c7921ede93a2e52939416170d357812"}, - {file = "pydantic_core-2.18.3-cp310-none-win32.whl", hash = "sha256:9f9e04afebd3ed8c15d67a564ed0a34b54e52136c6d40d14c5547b238390e779"}, - {file = "pydantic_core-2.18.3-cp310-none-win_amd64.whl", hash = "sha256:45e4ffbae34f7ae30d0047697e724e534a7ec0a82ef9994b7913a412c21462a0"}, - {file = "pydantic_core-2.18.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b9ebe8231726c49518b16b237b9fe0d7d361dd221302af511a83d4ada01183ab"}, - {file = "pydantic_core-2.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b8e20e15d18bf7dbb453be78a2d858f946f5cdf06c5072453dace00ab652e2b2"}, - {file = "pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0d9ff283cd3459fa0bf9b0256a2b6f01ac1ff9ffb034e24457b9035f75587cb"}, - {file = "pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f7ef5f0ebb77ba24c9970da18b771711edc5feaf00c10b18461e0f5f5949231"}, - {file = "pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73038d66614d2e5cde30435b5afdced2b473b4c77d4ca3a8624dd3e41a9c19be"}, - {file = "pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6afd5c867a74c4d314c557b5ea9520183fadfbd1df4c2d6e09fd0d990ce412cd"}, - {file = "pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd7df92f28d351bb9f12470f4c533cf03d1b52ec5a6e5c58c65b183055a60106"}, - {file = "pydantic_core-2.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:80aea0ffeb1049336043d07799eace1c9602519fb3192916ff525b0287b2b1e4"}, - {file = "pydantic_core-2.18.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaee40f25bba38132e655ffa3d1998a6d576ba7cf81deff8bfa189fb43fd2bbe"}, - {file = "pydantic_core-2.18.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9128089da8f4fe73f7a91973895ebf2502539d627891a14034e45fb9e707e26d"}, - {file = "pydantic_core-2.18.3-cp311-none-win32.whl", hash = "sha256:fec02527e1e03257aa25b1a4dcbe697b40a22f1229f5d026503e8b7ff6d2eda7"}, - {file = "pydantic_core-2.18.3-cp311-none-win_amd64.whl", hash = "sha256:58ff8631dbab6c7c982e6425da8347108449321f61fe427c52ddfadd66642af7"}, - {file = "pydantic_core-2.18.3-cp311-none-win_arm64.whl", hash = "sha256:3fc1c7f67f34c6c2ef9c213e0f2a351797cda98249d9ca56a70ce4ebcaba45f4"}, - {file = "pydantic_core-2.18.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f0928cde2ae416a2d1ebe6dee324709c6f73e93494d8c7aea92df99aab1fc40f"}, - {file = "pydantic_core-2.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bee9bb305a562f8b9271855afb6ce00223f545de3d68560b3c1649c7c5295e9"}, - {file = "pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e862823be114387257dacbfa7d78547165a85d7add33b446ca4f4fae92c7ff5c"}, - {file = "pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a36f78674cbddc165abab0df961b5f96b14461d05feec5e1f78da58808b97e7"}, - {file = "pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba905d184f62e7ddbb7a5a751d8a5c805463511c7b08d1aca4a3e8c11f2e5048"}, - {file = "pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fdd362f6a586e681ff86550b2379e532fee63c52def1c666887956748eaa326"}, - {file = "pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24b214b7ee3bd3b865e963dbed0f8bc5375f49449d70e8d407b567af3222aae4"}, - {file = "pydantic_core-2.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:691018785779766127f531674fa82bb368df5b36b461622b12e176c18e119022"}, - {file = "pydantic_core-2.18.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:60e4c625e6f7155d7d0dcac151edf5858102bc61bf959d04469ca6ee4e8381bd"}, - {file = "pydantic_core-2.18.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4e651e47d981c1b701dcc74ab8fec5a60a5b004650416b4abbef13db23bc7be"}, - {file = "pydantic_core-2.18.3-cp312-none-win32.whl", hash = "sha256:ffecbb5edb7f5ffae13599aec33b735e9e4c7676ca1633c60f2c606beb17efc5"}, - {file = "pydantic_core-2.18.3-cp312-none-win_amd64.whl", hash = "sha256:2c8333f6e934733483c7eddffdb094c143b9463d2af7e6bd85ebcb2d4a1b82c6"}, - {file = "pydantic_core-2.18.3-cp312-none-win_arm64.whl", hash = "sha256:7a20dded653e516a4655f4c98e97ccafb13753987434fe7cf044aa25f5b7d417"}, - {file = "pydantic_core-2.18.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:eecf63195be644b0396f972c82598cd15693550f0ff236dcf7ab92e2eb6d3522"}, - {file = "pydantic_core-2.18.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c44efdd3b6125419c28821590d7ec891c9cb0dff33a7a78d9d5c8b6f66b9702"}, - {file = "pydantic_core-2.18.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e59fca51ffbdd1638b3856779342ed69bcecb8484c1d4b8bdb237d0eb5a45e2"}, - {file = "pydantic_core-2.18.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70cf099197d6b98953468461d753563b28e73cf1eade2ffe069675d2657ed1d5"}, - {file = "pydantic_core-2.18.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63081a49dddc6124754b32a3774331467bfc3d2bd5ff8f10df36a95602560361"}, - {file = "pydantic_core-2.18.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:370059b7883485c9edb9655355ff46d912f4b03b009d929220d9294c7fd9fd60"}, - {file = "pydantic_core-2.18.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a64faeedfd8254f05f5cf6fc755023a7e1606af3959cfc1a9285744cc711044"}, - {file = "pydantic_core-2.18.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19d2e725de0f90d8671f89e420d36c3dd97639b98145e42fcc0e1f6d492a46dc"}, - {file = "pydantic_core-2.18.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:67bc078025d70ec5aefe6200ef094576c9d86bd36982df1301c758a9fff7d7f4"}, - {file = "pydantic_core-2.18.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:adf952c3f4100e203cbaf8e0c907c835d3e28f9041474e52b651761dc248a3c0"}, - {file = "pydantic_core-2.18.3-cp38-none-win32.whl", hash = "sha256:9a46795b1f3beb167eaee91736d5d17ac3a994bf2215a996aed825a45f897558"}, - {file = "pydantic_core-2.18.3-cp38-none-win_amd64.whl", hash = "sha256:200ad4e3133cb99ed82342a101a5abf3d924722e71cd581cc113fe828f727fbc"}, - {file = "pydantic_core-2.18.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:304378b7bf92206036c8ddd83a2ba7b7d1a5b425acafff637172a3aa72ad7083"}, - {file = "pydantic_core-2.18.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c826870b277143e701c9ccf34ebc33ddb4d072612683a044e7cce2d52f6c3fef"}, - {file = "pydantic_core-2.18.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e201935d282707394f3668380e41ccf25b5794d1b131cdd96b07f615a33ca4b1"}, - {file = "pydantic_core-2.18.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5560dda746c44b48bf82b3d191d74fe8efc5686a9ef18e69bdabccbbb9ad9442"}, - {file = "pydantic_core-2.18.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b32c2a1f8032570842257e4c19288eba9a2bba4712af542327de9a1204faff8"}, - {file = "pydantic_core-2.18.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:929c24e9dea3990bc8bcd27c5f2d3916c0c86f5511d2caa69e0d5290115344a9"}, - {file = "pydantic_core-2.18.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1a8376fef60790152564b0eab376b3e23dd6e54f29d84aad46f7b264ecca943"}, - {file = "pydantic_core-2.18.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dccf3ef1400390ddd1fb55bf0632209d39140552d068ee5ac45553b556780e06"}, - {file = "pydantic_core-2.18.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:41dbdcb0c7252b58fa931fec47937edb422c9cb22528f41cb8963665c372caf6"}, - {file = "pydantic_core-2.18.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:666e45cf071669fde468886654742fa10b0e74cd0fa0430a46ba6056b24fb0af"}, - {file = "pydantic_core-2.18.3-cp39-none-win32.whl", hash = "sha256:f9c08cabff68704a1b4667d33f534d544b8a07b8e5d039c37067fceb18789e78"}, - {file = "pydantic_core-2.18.3-cp39-none-win_amd64.whl", hash = "sha256:4afa5f5973e8572b5c0dcb4e2d4fda7890e7cd63329bd5cc3263a25c92ef0026"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:77319771a026f7c7d29c6ebc623de889e9563b7087911b46fd06c044a12aa5e9"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:df11fa992e9f576473038510d66dd305bcd51d7dd508c163a8c8fe148454e059"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d531076bdfb65af593326ffd567e6ab3da145020dafb9187a1d131064a55f97c"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d33ce258e4e6e6038f2b9e8b8a631d17d017567db43483314993b3ca345dcbbb"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1f9cd7f5635b719939019be9bda47ecb56e165e51dd26c9a217a433e3d0d59a9"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cd4a032bb65cc132cae1fe3e52877daecc2097965cd3914e44fbd12b00dae7c5"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f2718430098bcdf60402136c845e4126a189959d103900ebabb6774a5d9fdb"}, - {file = "pydantic_core-2.18.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0037a92cf0c580ed14e10953cdd26528e8796307bb8bb312dc65f71547df04d"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b95a0972fac2b1ff3c94629fc9081b16371dad870959f1408cc33b2f78ad347a"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a62e437d687cc148381bdd5f51e3e81f5b20a735c55f690c5be94e05da2b0d5c"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b367a73a414bbb08507da102dc2cde0fa7afe57d09b3240ce82a16d608a7679c"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ecce4b2360aa3f008da3327d652e74a0e743908eac306198b47e1c58b03dd2b"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd4435b8d83f0c9561a2a9585b1de78f1abb17cb0cef5f39bf6a4b47d19bafe3"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:616221a6d473c5b9aa83fa8982745441f6a4a62a66436be9445c65f241b86c94"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7e6382ce89a92bc1d0c0c5edd51e931432202b9080dc921d8d003e616402efd1"}, - {file = "pydantic_core-2.18.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff58f379345603d940e461eae474b6bbb6dab66ed9a851ecd3cb3709bf4dcf6a"}, - {file = "pydantic_core-2.18.3.tar.gz", hash = "sha256:432e999088d85c8f36b9a3f769a8e2b57aabd817bbb729a90d1fe7f18f6f1f39"}, + {file = "pydantic_core-2.18.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f76d0ad001edd426b92233d45c746fd08f467d56100fd8f30e9ace4b005266e4"}, + {file = "pydantic_core-2.18.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59ff3e89f4eaf14050c8022011862df275b552caef8082e37b542b066ce1ff26"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a55b5b16c839df1070bc113c1f7f94a0af4433fcfa1b41799ce7606e5c79ce0a"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d0dcc59664fcb8974b356fe0a18a672d6d7cf9f54746c05f43275fc48636851"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8951eee36c57cd128f779e641e21eb40bc5073eb28b2d23f33eb0ef14ffb3f5d"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4701b19f7e3a06ea655513f7938de6f108123bf7c86bbebb1196eb9bd35cf724"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00a3f196329e08e43d99b79b286d60ce46bed10f2280d25a1718399457e06be"}, + {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97736815b9cc893b2b7f663628e63f436018b75f44854c8027040e05230eeddb"}, + {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6891a2ae0e8692679c07728819b6e2b822fb30ca7445f67bbf6509b25a96332c"}, + {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bc4ff9805858bd54d1a20efff925ccd89c9d2e7cf4986144b30802bf78091c3e"}, + {file = "pydantic_core-2.18.4-cp310-none-win32.whl", hash = "sha256:1b4de2e51bbcb61fdebd0ab86ef28062704f62c82bbf4addc4e37fa4b00b7cbc"}, + {file = "pydantic_core-2.18.4-cp310-none-win_amd64.whl", hash = "sha256:6a750aec7bf431517a9fd78cb93c97b9b0c496090fee84a47a0d23668976b4b0"}, + {file = "pydantic_core-2.18.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:942ba11e7dfb66dc70f9ae66b33452f51ac7bb90676da39a7345e99ffb55402d"}, + {file = "pydantic_core-2.18.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2ebef0e0b4454320274f5e83a41844c63438fdc874ea40a8b5b4ecb7693f1c4"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a642295cd0c8df1b86fc3dced1d067874c353a188dc8e0f744626d49e9aa51c4"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f09baa656c904807e832cf9cce799c6460c450c4ad80803517032da0cd062e2"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98906207f29bc2c459ff64fa007afd10a8c8ac080f7e4d5beff4c97086a3dabd"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19894b95aacfa98e7cb093cd7881a0c76f55731efad31073db4521e2b6ff5b7d"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fbbdc827fe5e42e4d196c746b890b3d72876bdbf160b0eafe9f0334525119c8"}, + {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f85d05aa0918283cf29a30b547b4df2fbb56b45b135f9e35b6807cb28bc47951"}, + {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e85637bc8fe81ddb73fda9e56bab24560bdddfa98aa64f87aaa4e4b6730c23d2"}, + {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2f5966897e5461f818e136b8451d0551a2e77259eb0f73a837027b47dc95dab9"}, + {file = "pydantic_core-2.18.4-cp311-none-win32.whl", hash = "sha256:44c7486a4228413c317952e9d89598bcdfb06399735e49e0f8df643e1ccd0558"}, + {file = "pydantic_core-2.18.4-cp311-none-win_amd64.whl", hash = "sha256:8a7164fe2005d03c64fd3b85649891cd4953a8de53107940bf272500ba8a788b"}, + {file = "pydantic_core-2.18.4-cp311-none-win_arm64.whl", hash = "sha256:4e99bc050fe65c450344421017f98298a97cefc18c53bb2f7b3531eb39bc7805"}, + {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, + {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, + {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, + {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, + {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, + {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, + {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, + {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, + {file = "pydantic_core-2.18.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:823be1deb01793da05ecb0484d6c9e20baebb39bd42b5d72636ae9cf8350dbd2"}, + {file = "pydantic_core-2.18.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebef0dd9bf9b812bf75bda96743f2a6c5734a02092ae7f721c048d156d5fabae"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d6df168efb88d7d522664693607b80b4080be6750c913eefb77e34c12c71a"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9899c94762343f2cc2fc64c13e7cae4c3cc65cdfc87dd810a31654c9b7358cc"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99457f184ad90235cfe8461c4d70ab7dd2680e28821c29eca00252ba90308c78"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f469a3d2a2fdafe99296a87e8a4c37748b5080a26b806a707f25a902c040a8"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cdf28938ac6b8b49ae5e92f2735056a7ba99c9b110a474473fd71185c1af5d"}, + {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:938cb21650855054dc54dfd9120a851c974f95450f00683399006aa6e8abb057"}, + {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:44cd83ab6a51da80fb5adbd9560e26018e2ac7826f9626bc06ca3dc074cd198b"}, + {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:972658f4a72d02b8abfa2581d92d59f59897d2e9f7e708fdabe922f9087773af"}, + {file = "pydantic_core-2.18.4-cp38-none-win32.whl", hash = "sha256:1d886dc848e60cb7666f771e406acae54ab279b9f1e4143babc9c2258213daa2"}, + {file = "pydantic_core-2.18.4-cp38-none-win_amd64.whl", hash = "sha256:bb4462bd43c2460774914b8525f79b00f8f407c945d50881568f294c1d9b4443"}, + {file = "pydantic_core-2.18.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:44a688331d4a4e2129140a8118479443bd6f1905231138971372fcde37e43528"}, + {file = "pydantic_core-2.18.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2fdd81edd64342c85ac7cf2753ccae0b79bf2dfa063785503cb85a7d3593223"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86110d7e1907ab36691f80b33eb2da87d780f4739ae773e5fc83fb272f88825f"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46387e38bd641b3ee5ce247563b60c5ca098da9c56c75c157a05eaa0933ed154"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:123c3cec203e3f5ac7b000bd82235f1a3eced8665b63d18be751f115588fea30"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1803ac5c32ec324c5261c7209e8f8ce88e83254c4e1aebdc8b0a39f9ddb443"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53db086f9f6ab2b4061958d9c276d1dbe3690e8dd727d6abf2321d6cce37fa94"}, + {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abc267fa9837245cc28ea6929f19fa335f3dc330a35d2e45509b6566dc18be23"}, + {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0d829524aaefdebccb869eed855e2d04c21d2d7479b6cada7ace5448416597b"}, + {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:509daade3b8649f80d4e5ff21aa5673e4ebe58590b25fe42fac5f0f52c6f034a"}, + {file = "pydantic_core-2.18.4-cp39-none-win32.whl", hash = "sha256:ca26a1e73c48cfc54c4a76ff78df3727b9d9f4ccc8dbee4ae3f73306a591676d"}, + {file = "pydantic_core-2.18.4-cp39-none-win_amd64.whl", hash = "sha256:c67598100338d5d985db1b3d21f3619ef392e185e71b8d52bceacc4a7771ea7e"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, + {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, + {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, + {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, ] [package.dependencies] @@ -1689,17 +1781,16 @@ files = [ [[package]] name = "pygments" -version = "2.17.2" +version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, - {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] [package.extras] -plugins = ["importlib-metadata"] windows-terminal = ["colorama (>=0.4.6)"] [[package]] @@ -1721,17 +1812,17 @@ tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] name = "pymdown-extensions" -version = "10.7.1" +version = "10.8.1" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.7.1-py3-none-any.whl", hash = "sha256:f5cc7000d7ff0d1ce9395d216017fa4df3dde800afb1fb72d1c7d3fd35e710f4"}, - {file = "pymdown_extensions-10.7.1.tar.gz", hash = "sha256:c70e146bdd83c744ffc766b4671999796aba18842b268510a329f7f64700d584"}, + {file = "pymdown_extensions-10.8.1-py3-none-any.whl", hash = "sha256:f938326115884f48c6059c67377c46cf631c733ef3629b6eed1349989d1b30cb"}, + {file = "pymdown_extensions-10.8.1.tar.gz", hash = "sha256:3ab1db5c9e21728dabf75192d71471f8e50f216627e9a1fa9535ecb0231b9940"}, ] [package.dependencies] -markdown = ">=3.5" +markdown = ">=3.6" pyyaml = "*" [package.extras] @@ -1739,13 +1830,13 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pytest" -version = "8.2.1" +version = "8.2.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"}, - {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"}, + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, ] [package.dependencies] @@ -1920,7 +2011,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1928,16 +2018,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1954,7 +2036,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1962,7 +2043,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1982,117 +2062,219 @@ files = [ [package.dependencies] pyyaml = "*" +[[package]] +name = "pyzmq" +version = "25.1.2" +description = "Python bindings for 0MQ" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyzmq-25.1.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:e624c789359f1a16f83f35e2c705d07663ff2b4d4479bad35621178d8f0f6ea4"}, + {file = "pyzmq-25.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49151b0efece79f6a79d41a461d78535356136ee70084a1c22532fc6383f4ad0"}, + {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9a5f194cf730f2b24d6af1f833c14c10f41023da46a7f736f48b6d35061e76e"}, + {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faf79a302f834d9e8304fafdc11d0d042266667ac45209afa57e5efc998e3872"}, + {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f51a7b4ead28d3fca8dda53216314a553b0f7a91ee8fc46a72b402a78c3e43d"}, + {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0ddd6d71d4ef17ba5a87becf7ddf01b371eaba553c603477679ae817a8d84d75"}, + {file = "pyzmq-25.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:246747b88917e4867e2367b005fc8eefbb4a54b7db363d6c92f89d69abfff4b6"}, + {file = "pyzmq-25.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:00c48ae2fd81e2a50c3485de1b9d5c7c57cd85dc8ec55683eac16846e57ac979"}, + {file = "pyzmq-25.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5a68d491fc20762b630e5db2191dd07ff89834086740f70e978bb2ef2668be08"}, + {file = "pyzmq-25.1.2-cp310-cp310-win32.whl", hash = "sha256:09dfe949e83087da88c4a76767df04b22304a682d6154de2c572625c62ad6886"}, + {file = "pyzmq-25.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:fa99973d2ed20417744fca0073390ad65ce225b546febb0580358e36aa90dba6"}, + {file = "pyzmq-25.1.2-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:82544e0e2d0c1811482d37eef297020a040c32e0687c1f6fc23a75b75db8062c"}, + {file = "pyzmq-25.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01171fc48542348cd1a360a4b6c3e7d8f46cdcf53a8d40f84db6707a6768acc1"}, + {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc69c96735ab501419c432110016329bf0dea8898ce16fab97c6d9106dc0b348"}, + {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3e124e6b1dd3dfbeb695435dff0e383256655bb18082e094a8dd1f6293114642"}, + {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7598d2ba821caa37a0f9d54c25164a4fa351ce019d64d0b44b45540950458840"}, + {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d1299d7e964c13607efd148ca1f07dcbf27c3ab9e125d1d0ae1d580a1682399d"}, + {file = "pyzmq-25.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4e6f689880d5ad87918430957297c975203a082d9a036cc426648fcbedae769b"}, + {file = "pyzmq-25.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cc69949484171cc961e6ecd4a8911b9ce7a0d1f738fcae717177c231bf77437b"}, + {file = "pyzmq-25.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9880078f683466b7f567b8624bfc16cad65077be046b6e8abb53bed4eeb82dd3"}, + {file = "pyzmq-25.1.2-cp311-cp311-win32.whl", hash = "sha256:4e5837af3e5aaa99a091302df5ee001149baff06ad22b722d34e30df5f0d9097"}, + {file = "pyzmq-25.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:25c2dbb97d38b5ac9fd15586e048ec5eb1e38f3d47fe7d92167b0c77bb3584e9"}, + {file = "pyzmq-25.1.2-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:11e70516688190e9c2db14fcf93c04192b02d457b582a1f6190b154691b4c93a"}, + {file = "pyzmq-25.1.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:313c3794d650d1fccaaab2df942af9f2c01d6217c846177cfcbc693c7410839e"}, + {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b3cbba2f47062b85fe0ef9de5b987612140a9ba3a9c6d2543c6dec9f7c2ab27"}, + {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc31baa0c32a2ca660784d5af3b9487e13b61b3032cb01a115fce6588e1bed30"}, + {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02c9087b109070c5ab0b383079fa1b5f797f8d43e9a66c07a4b8b8bdecfd88ee"}, + {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f8429b17cbb746c3e043cb986328da023657e79d5ed258b711c06a70c2ea7537"}, + {file = "pyzmq-25.1.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5074adeacede5f810b7ef39607ee59d94e948b4fd954495bdb072f8c54558181"}, + {file = "pyzmq-25.1.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7ae8f354b895cbd85212da245f1a5ad8159e7840e37d78b476bb4f4c3f32a9fe"}, + {file = "pyzmq-25.1.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b264bf2cc96b5bc43ce0e852be995e400376bd87ceb363822e2cb1964fcdc737"}, + {file = "pyzmq-25.1.2-cp312-cp312-win32.whl", hash = "sha256:02bbc1a87b76e04fd780b45e7f695471ae6de747769e540da909173d50ff8e2d"}, + {file = "pyzmq-25.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:ced111c2e81506abd1dc142e6cd7b68dd53747b3b7ae5edbea4578c5eeff96b7"}, + {file = "pyzmq-25.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7b6d09a8962a91151f0976008eb7b29b433a560fde056ec7a3db9ec8f1075438"}, + {file = "pyzmq-25.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:967668420f36878a3c9ecb5ab33c9d0ff8d054f9c0233d995a6d25b0e95e1b6b"}, + {file = "pyzmq-25.1.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5edac3f57c7ddaacdb4d40f6ef2f9e299471fc38d112f4bc6d60ab9365445fb0"}, + {file = "pyzmq-25.1.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0dabfb10ef897f3b7e101cacba1437bd3a5032ee667b7ead32bbcdd1a8422fe7"}, + {file = "pyzmq-25.1.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2c6441e0398c2baacfe5ba30c937d274cfc2dc5b55e82e3749e333aabffde561"}, + {file = "pyzmq-25.1.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:16b726c1f6c2e7625706549f9dbe9b06004dfbec30dbed4bf50cbdfc73e5b32a"}, + {file = "pyzmq-25.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:a86c2dd76ef71a773e70551a07318b8e52379f58dafa7ae1e0a4be78efd1ff16"}, + {file = "pyzmq-25.1.2-cp36-cp36m-win32.whl", hash = "sha256:359f7f74b5d3c65dae137f33eb2bcfa7ad9ebefd1cab85c935f063f1dbb245cc"}, + {file = "pyzmq-25.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:55875492f820d0eb3417b51d96fea549cde77893ae3790fd25491c5754ea2f68"}, + {file = "pyzmq-25.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b8c8a419dfb02e91b453615c69568442e897aaf77561ee0064d789705ff37a92"}, + {file = "pyzmq-25.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8807c87fa893527ae8a524c15fc505d9950d5e856f03dae5921b5e9aa3b8783b"}, + {file = "pyzmq-25.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5e319ed7d6b8f5fad9b76daa0a68497bc6f129858ad956331a5835785761e003"}, + {file = "pyzmq-25.1.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3c53687dde4d9d473c587ae80cc328e5b102b517447456184b485587ebd18b62"}, + {file = "pyzmq-25.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9add2e5b33d2cd765ad96d5eb734a5e795a0755f7fc49aa04f76d7ddda73fd70"}, + {file = "pyzmq-25.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e690145a8c0c273c28d3b89d6fb32c45e0d9605b2293c10e650265bf5c11cfec"}, + {file = "pyzmq-25.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:00a06faa7165634f0cac1abb27e54d7a0b3b44eb9994530b8ec73cf52e15353b"}, + {file = "pyzmq-25.1.2-cp37-cp37m-win32.whl", hash = "sha256:0f97bc2f1f13cb16905a5f3e1fbdf100e712d841482b2237484360f8bc4cb3d7"}, + {file = "pyzmq-25.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6cc0020b74b2e410287e5942e1e10886ff81ac77789eb20bec13f7ae681f0fdd"}, + {file = "pyzmq-25.1.2-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:bef02cfcbded83473bdd86dd8d3729cd82b2e569b75844fb4ea08fee3c26ae41"}, + {file = "pyzmq-25.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e10a4b5a4b1192d74853cc71a5e9fd022594573926c2a3a4802020360aa719d8"}, + {file = "pyzmq-25.1.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8c5f80e578427d4695adac6fdf4370c14a2feafdc8cb35549c219b90652536ae"}, + {file = "pyzmq-25.1.2-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5dde6751e857910c1339890f3524de74007958557593b9e7e8c5f01cd919f8a7"}, + {file = "pyzmq-25.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea1608dd169da230a0ad602d5b1ebd39807ac96cae1845c3ceed39af08a5c6df"}, + {file = "pyzmq-25.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0f513130c4c361201da9bc69df25a086487250e16b5571ead521b31ff6b02220"}, + {file = "pyzmq-25.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:019744b99da30330798bb37df33549d59d380c78e516e3bab9c9b84f87a9592f"}, + {file = "pyzmq-25.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2e2713ef44be5d52dd8b8e2023d706bf66cb22072e97fc71b168e01d25192755"}, + {file = "pyzmq-25.1.2-cp38-cp38-win32.whl", hash = "sha256:07cd61a20a535524906595e09344505a9bd46f1da7a07e504b315d41cd42eb07"}, + {file = "pyzmq-25.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb7e49a17fb8c77d3119d41a4523e432eb0c6932187c37deb6fbb00cc3028088"}, + {file = "pyzmq-25.1.2-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:94504ff66f278ab4b7e03e4cba7e7e400cb73bfa9d3d71f58d8972a8dc67e7a6"}, + {file = "pyzmq-25.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6dd0d50bbf9dca1d0bdea219ae6b40f713a3fb477c06ca3714f208fd69e16fd8"}, + {file = "pyzmq-25.1.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:004ff469d21e86f0ef0369717351073e0e577428e514c47c8480770d5e24a565"}, + {file = "pyzmq-25.1.2-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c0b5ca88a8928147b7b1e2dfa09f3b6c256bc1135a1338536cbc9ea13d3b7add"}, + {file = "pyzmq-25.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9a79f1d2495b167119d02be7448bfba57fad2a4207c4f68abc0bab4b92925b"}, + {file = "pyzmq-25.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:518efd91c3d8ac9f9b4f7dd0e2b7b8bf1a4fe82a308009016b07eaa48681af82"}, + {file = "pyzmq-25.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1ec23bd7b3a893ae676d0e54ad47d18064e6c5ae1fadc2f195143fb27373f7f6"}, + {file = "pyzmq-25.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db36c27baed588a5a8346b971477b718fdc66cf5b80cbfbd914b4d6d355e44e2"}, + {file = "pyzmq-25.1.2-cp39-cp39-win32.whl", hash = "sha256:39b1067f13aba39d794a24761e385e2eddc26295826530a8c7b6c6c341584289"}, + {file = "pyzmq-25.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:8e9f3fabc445d0ce320ea2c59a75fe3ea591fdbdeebec5db6de530dd4b09412e"}, + {file = "pyzmq-25.1.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a8c1d566344aee826b74e472e16edae0a02e2a044f14f7c24e123002dcff1c05"}, + {file = "pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:759cfd391a0996345ba94b6a5110fca9c557ad4166d86a6e81ea526c376a01e8"}, + {file = "pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c61e346ac34b74028ede1c6b4bcecf649d69b707b3ff9dc0fab453821b04d1e"}, + {file = "pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cb8fc1f8d69b411b8ec0b5f1ffbcaf14c1db95b6bccea21d83610987435f1a4"}, + {file = "pyzmq-25.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3c00c9b7d1ca8165c610437ca0c92e7b5607b2f9076f4eb4b095c85d6e680a1d"}, + {file = "pyzmq-25.1.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:df0c7a16ebb94452d2909b9a7b3337940e9a87a824c4fc1c7c36bb4404cb0cde"}, + {file = "pyzmq-25.1.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:45999e7f7ed5c390f2e87ece7f6c56bf979fb213550229e711e45ecc7d42ccb8"}, + {file = "pyzmq-25.1.2-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ac170e9e048b40c605358667aca3d94e98f604a18c44bdb4c102e67070f3ac9b"}, + {file = "pyzmq-25.1.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1b604734bec94f05f81b360a272fc824334267426ae9905ff32dc2be433ab96"}, + {file = "pyzmq-25.1.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a793ac733e3d895d96f865f1806f160696422554e46d30105807fdc9841b9f7d"}, + {file = "pyzmq-25.1.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0806175f2ae5ad4b835ecd87f5f85583316b69f17e97786f7443baaf54b9bb98"}, + {file = "pyzmq-25.1.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ef12e259e7bc317c7597d4f6ef59b97b913e162d83b421dd0db3d6410f17a244"}, + {file = "pyzmq-25.1.2-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea253b368eb41116011add00f8d5726762320b1bda892f744c91997b65754d73"}, + {file = "pyzmq-25.1.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b9b1f2ad6498445a941d9a4fee096d387fee436e45cc660e72e768d3d8ee611"}, + {file = "pyzmq-25.1.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8b14c75979ce932c53b79976a395cb2a8cd3aaf14aef75e8c2cb55a330b9b49d"}, + {file = "pyzmq-25.1.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:889370d5174a741a62566c003ee8ddba4b04c3f09a97b8000092b7ca83ec9c49"}, + {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a18fff090441a40ffda8a7f4f18f03dc56ae73f148f1832e109f9bffa85df15"}, + {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99a6b36f95c98839ad98f8c553d8507644c880cf1e0a57fe5e3a3f3969040882"}, + {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4345c9a27f4310afbb9c01750e9461ff33d6fb74cd2456b107525bbeebcb5be3"}, + {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3516e0b6224cf6e43e341d56da15fd33bdc37fa0c06af4f029f7d7dfceceabbc"}, + {file = "pyzmq-25.1.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:146b9b1f29ead41255387fb07be56dc29639262c0f7344f570eecdcd8d683314"}, + {file = "pyzmq-25.1.2.tar.gz", hash = "sha256:93f1aa311e8bb912e34f004cf186407a4e90eec4f0ecc0efd26056bf7eda0226"}, +] + +[package.dependencies] +cffi = {version = "*", markers = "implementation_name == \"pypy\""} + +[[package]] +name = "radixtarget" +version = "1.1.0.18" +description = "Check whether an IP address belongs to a cloud provider" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "radixtarget-1.1.0.18-py3-none-any.whl", hash = "sha256:05e95de6afb0ee4dfa31c53bd25a34a193ae5bb46dc7624e0424bbcfed2c4cea"}, + {file = "radixtarget-1.1.0.18.tar.gz", hash = "sha256:1a3306891a22f7ff2c71d6cd42202af8852cdb4fb68e9a1e9a76a3f60aa98ab6"}, +] + [[package]] name = "regex" -version = "2024.4.16" +version = "2024.5.15" description = "Alternative regular expression module, to replace re." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "regex-2024.4.16-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb83cc090eac63c006871fd24db5e30a1f282faa46328572661c0a24a2323a08"}, - {file = "regex-2024.4.16-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c91e1763696c0eb66340c4df98623c2d4e77d0746b8f8f2bee2c6883fd1fe18"}, - {file = "regex-2024.4.16-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:10188fe732dec829c7acca7422cdd1bf57d853c7199d5a9e96bb4d40db239c73"}, - {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:956b58d692f235cfbf5b4f3abd6d99bf102f161ccfe20d2fd0904f51c72c4c66"}, - {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a70b51f55fd954d1f194271695821dd62054d949efd6368d8be64edd37f55c86"}, - {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c02fcd2bf45162280613d2e4a1ca3ac558ff921ae4e308ecb307650d3a6ee51"}, - {file = "regex-2024.4.16-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4ed75ea6892a56896d78f11006161eea52c45a14994794bcfa1654430984b22"}, - {file = "regex-2024.4.16-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd727ad276bb91928879f3aa6396c9a1d34e5e180dce40578421a691eeb77f47"}, - {file = "regex-2024.4.16-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7cbc5d9e8a1781e7be17da67b92580d6ce4dcef5819c1b1b89f49d9678cc278c"}, - {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:78fddb22b9ef810b63ef341c9fcf6455232d97cfe03938cbc29e2672c436670e"}, - {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:445ca8d3c5a01309633a0c9db57150312a181146315693273e35d936472df912"}, - {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:95399831a206211d6bc40224af1c635cb8790ddd5c7493e0bd03b85711076a53"}, - {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:7731728b6568fc286d86745f27f07266de49603a6fdc4d19c87e8c247be452af"}, - {file = "regex-2024.4.16-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4facc913e10bdba42ec0aee76d029aedda628161a7ce4116b16680a0413f658a"}, - {file = "regex-2024.4.16-cp310-cp310-win32.whl", hash = "sha256:911742856ce98d879acbea33fcc03c1d8dc1106234c5e7d068932c945db209c0"}, - {file = "regex-2024.4.16-cp310-cp310-win_amd64.whl", hash = "sha256:e0a2df336d1135a0b3a67f3bbf78a75f69562c1199ed9935372b82215cddd6e2"}, - {file = "regex-2024.4.16-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1210365faba7c2150451eb78ec5687871c796b0f1fa701bfd2a4a25420482d26"}, - {file = "regex-2024.4.16-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9ab40412f8cd6f615bfedea40c8bf0407d41bf83b96f6fc9ff34976d6b7037fd"}, - {file = "regex-2024.4.16-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd80d1280d473500d8086d104962a82d77bfbf2b118053824b7be28cd5a79ea5"}, - {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bb966fdd9217e53abf824f437a5a2d643a38d4fd5fd0ca711b9da683d452969"}, - {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20b7a68444f536365af42a75ccecb7ab41a896a04acf58432db9e206f4e525d6"}, - {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b74586dd0b039c62416034f811d7ee62810174bb70dffcca6439f5236249eb09"}, - {file = "regex-2024.4.16-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c8290b44d8b0af4e77048646c10c6e3aa583c1ca67f3b5ffb6e06cf0c6f0f89"}, - {file = "regex-2024.4.16-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2d80a6749724b37853ece57988b39c4e79d2b5fe2869a86e8aeae3bbeef9eb0"}, - {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3a1018e97aeb24e4f939afcd88211ace472ba566efc5bdf53fd8fd7f41fa7170"}, - {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8d015604ee6204e76569d2f44e5a210728fa917115bef0d102f4107e622b08d5"}, - {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:3d5ac5234fb5053850d79dd8eb1015cb0d7d9ed951fa37aa9e6249a19aa4f336"}, - {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:0a38d151e2cdd66d16dab550c22f9521ba79761423b87c01dae0a6e9add79c0d"}, - {file = "regex-2024.4.16-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:159dc4e59a159cb8e4e8f8961eb1fa5d58f93cb1acd1701d8aff38d45e1a84a6"}, - {file = "regex-2024.4.16-cp311-cp311-win32.whl", hash = "sha256:ba2336d6548dee3117520545cfe44dc28a250aa091f8281d28804aa8d707d93d"}, - {file = "regex-2024.4.16-cp311-cp311-win_amd64.whl", hash = "sha256:8f83b6fd3dc3ba94d2b22717f9c8b8512354fd95221ac661784df2769ea9bba9"}, - {file = "regex-2024.4.16-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:80b696e8972b81edf0af2a259e1b2a4a661f818fae22e5fa4fa1a995fb4a40fd"}, - {file = "regex-2024.4.16-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d61ae114d2a2311f61d90c2ef1358518e8f05eafda76eaf9c772a077e0b465ec"}, - {file = "regex-2024.4.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ba6745440b9a27336443b0c285d705ce73adb9ec90e2f2004c64d95ab5a7598"}, - {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295004b2dd37b0835ea5c14a33e00e8cfa3c4add4d587b77287825f3418d310"}, - {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4aba818dcc7263852aabb172ec27b71d2abca02a593b95fa79351b2774eb1d2b"}, - {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0800631e565c47520aaa04ae38b96abc5196fe8b4aa9bd864445bd2b5848a7a"}, - {file = "regex-2024.4.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08dea89f859c3df48a440dbdcd7b7155bc675f2fa2ec8c521d02dc69e877db70"}, - {file = "regex-2024.4.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eeaa0b5328b785abc344acc6241cffde50dc394a0644a968add75fcefe15b9d4"}, - {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4e819a806420bc010489f4e741b3036071aba209f2e0989d4750b08b12a9343f"}, - {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c2d0e7cbb6341e830adcbfa2479fdeebbfbb328f11edd6b5675674e7a1e37730"}, - {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:91797b98f5e34b6a49f54be33f72e2fb658018ae532be2f79f7c63b4ae225145"}, - {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:d2da13568eff02b30fd54fccd1e042a70fe920d816616fda4bf54ec705668d81"}, - {file = "regex-2024.4.16-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:370c68dc5570b394cbaadff50e64d705f64debed30573e5c313c360689b6aadc"}, - {file = "regex-2024.4.16-cp312-cp312-win32.whl", hash = "sha256:904c883cf10a975b02ab3478bce652f0f5346a2c28d0a8521d97bb23c323cc8b"}, - {file = "regex-2024.4.16-cp312-cp312-win_amd64.whl", hash = "sha256:785c071c982dce54d44ea0b79cd6dfafddeccdd98cfa5f7b86ef69b381b457d9"}, - {file = "regex-2024.4.16-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e2f142b45c6fed48166faeb4303b4b58c9fcd827da63f4cf0a123c3480ae11fb"}, - {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87ab229332ceb127a165612d839ab87795972102cb9830e5f12b8c9a5c1b508"}, - {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81500ed5af2090b4a9157a59dbc89873a25c33db1bb9a8cf123837dcc9765047"}, - {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b340cccad138ecb363324aa26893963dcabb02bb25e440ebdf42e30963f1a4e0"}, - {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c72608e70f053643437bd2be0608f7f1c46d4022e4104d76826f0839199347a"}, - {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a01fe2305e6232ef3e8f40bfc0f0f3a04def9aab514910fa4203bafbc0bb4682"}, - {file = "regex-2024.4.16-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:03576e3a423d19dda13e55598f0fd507b5d660d42c51b02df4e0d97824fdcae3"}, - {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:549c3584993772e25f02d0656ac48abdda73169fe347263948cf2b1cead622f3"}, - {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:34422d5a69a60b7e9a07a690094e824b66f5ddc662a5fc600d65b7c174a05f04"}, - {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:5f580c651a72b75c39e311343fe6875d6f58cf51c471a97f15a938d9fe4e0d37"}, - {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3399dd8a7495bbb2bacd59b84840eef9057826c664472e86c91d675d007137f5"}, - {file = "regex-2024.4.16-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8d1f86f3f4e2388aa3310b50694ac44daefbd1681def26b4519bd050a398dc5a"}, - {file = "regex-2024.4.16-cp37-cp37m-win32.whl", hash = "sha256:dd5acc0a7d38fdc7a3a6fd3ad14c880819008ecb3379626e56b163165162cc46"}, - {file = "regex-2024.4.16-cp37-cp37m-win_amd64.whl", hash = "sha256:ba8122e3bb94ecda29a8de4cf889f600171424ea586847aa92c334772d200331"}, - {file = "regex-2024.4.16-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:743deffdf3b3481da32e8a96887e2aa945ec6685af1cfe2bcc292638c9ba2f48"}, - {file = "regex-2024.4.16-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7571f19f4a3fd00af9341c7801d1ad1967fc9c3f5e62402683047e7166b9f2b4"}, - {file = "regex-2024.4.16-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:df79012ebf6f4efb8d307b1328226aef24ca446b3ff8d0e30202d7ebcb977a8c"}, - {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e757d475953269fbf4b441207bb7dbdd1c43180711b6208e129b637792ac0b93"}, - {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4313ab9bf6a81206c8ac28fdfcddc0435299dc88cad12cc6305fd0e78b81f9e4"}, - {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d83c2bc678453646f1a18f8db1e927a2d3f4935031b9ad8a76e56760461105dd"}, - {file = "regex-2024.4.16-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9df1bfef97db938469ef0a7354b2d591a2d438bc497b2c489471bec0e6baf7c4"}, - {file = "regex-2024.4.16-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62120ed0de69b3649cc68e2965376048793f466c5a6c4370fb27c16c1beac22d"}, - {file = "regex-2024.4.16-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c2ef6f7990b6e8758fe48ad08f7e2f66c8f11dc66e24093304b87cae9037bb4a"}, - {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8fc6976a3395fe4d1fbeb984adaa8ec652a1e12f36b56ec8c236e5117b585427"}, - {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:03e68f44340528111067cecf12721c3df4811c67268b897fbe695c95f860ac42"}, - {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ec7e0043b91115f427998febaa2beb82c82df708168b35ece3accb610b91fac1"}, - {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c21fc21a4c7480479d12fd8e679b699f744f76bb05f53a1d14182b31f55aac76"}, - {file = "regex-2024.4.16-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:12f6a3f2f58bb7344751919a1876ee1b976fe08b9ffccb4bbea66f26af6017b9"}, - {file = "regex-2024.4.16-cp38-cp38-win32.whl", hash = "sha256:479595a4fbe9ed8f8f72c59717e8cf222da2e4c07b6ae5b65411e6302af9708e"}, - {file = "regex-2024.4.16-cp38-cp38-win_amd64.whl", hash = "sha256:0534b034fba6101611968fae8e856c1698da97ce2efb5c2b895fc8b9e23a5834"}, - {file = "regex-2024.4.16-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7ccdd1c4a3472a7533b0a7aa9ee34c9a2bef859ba86deec07aff2ad7e0c3b94"}, - {file = "regex-2024.4.16-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f2f017c5be19984fbbf55f8af6caba25e62c71293213f044da3ada7091a4455"}, - {file = "regex-2024.4.16-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:803b8905b52de78b173d3c1e83df0efb929621e7b7c5766c0843704d5332682f"}, - {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:684008ec44ad275832a5a152f6e764bbe1914bea10968017b6feaecdad5736e0"}, - {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65436dce9fdc0aeeb0a0effe0839cb3d6a05f45aa45a4d9f9c60989beca78b9c"}, - {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea355eb43b11764cf799dda62c658c4d2fdb16af41f59bb1ccfec517b60bcb07"}, - {file = "regex-2024.4.16-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c1165f3809ce7774f05cb74e5408cd3aa93ee8573ae959a97a53db3ca3180d"}, - {file = "regex-2024.4.16-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cccc79a9be9b64c881f18305a7c715ba199e471a3973faeb7ba84172abb3f317"}, - {file = "regex-2024.4.16-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00169caa125f35d1bca6045d65a662af0202704489fada95346cfa092ec23f39"}, - {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6cc38067209354e16c5609b66285af17a2863a47585bcf75285cab33d4c3b8df"}, - {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:23cff1b267038501b179ccbbd74a821ac4a7192a1852d1d558e562b507d46013"}, - {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d320b3bf82a39f248769fc7f188e00f93526cc0fe739cfa197868633d44701"}, - {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:89ec7f2c08937421bbbb8b48c54096fa4f88347946d4747021ad85f1b3021b3c"}, - {file = "regex-2024.4.16-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4918fd5f8b43aa7ec031e0fef1ee02deb80b6afd49c85f0790be1dc4ce34cb50"}, - {file = "regex-2024.4.16-cp39-cp39-win32.whl", hash = "sha256:684e52023aec43bdf0250e843e1fdd6febbe831bd9d52da72333fa201aaa2335"}, - {file = "regex-2024.4.16-cp39-cp39-win_amd64.whl", hash = "sha256:e697e1c0238133589e00c244a8b676bc2cfc3ab4961318d902040d099fec7483"}, - {file = "regex-2024.4.16.tar.gz", hash = "sha256:fa454d26f2e87ad661c4f0c5a5fe4cf6aab1e307d1b94f16ffdfcb089ba685c0"}, + {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"}, + {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"}, + {file = "regex-2024.5.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0bd000c6e266927cb7a1bc39d55be95c4b4f65c5be53e659537537e019232b1"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eaa7ddaf517aa095fa8da0b5015c44d03da83f5bd49c87961e3c997daed0de7"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba68168daedb2c0bab7fd7e00ced5ba90aebf91024dea3c88ad5063c2a562cca"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e8d717bca3a6e2064fc3a08df5cbe366369f4b052dcd21b7416e6d71620dca1"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1337b7dbef9b2f71121cdbf1e97e40de33ff114801263b275aafd75303bd62b5"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9ebd0a36102fcad2f03696e8af4ae682793a5d30b46c647eaf280d6cfb32796"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9efa1a32ad3a3ea112224897cdaeb6aa00381627f567179c0314f7b65d354c62"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1595f2d10dff3d805e054ebdc41c124753631b6a471b976963c7b28543cf13b0"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b802512f3e1f480f41ab5f2cfc0e2f761f08a1f41092d6718868082fc0d27143"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a0981022dccabca811e8171f913de05720590c915b033b7e601f35ce4ea7019f"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:19068a6a79cf99a19ccefa44610491e9ca02c2be3305c7760d3831d38a467a6f"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b5269484f6126eee5e687785e83c6b60aad7663dafe842b34691157e5083e53"}, + {file = "regex-2024.5.15-cp310-cp310-win32.whl", hash = "sha256:ada150c5adfa8fbcbf321c30c751dc67d2f12f15bd183ffe4ec7cde351d945b3"}, + {file = "regex-2024.5.15-cp310-cp310-win_amd64.whl", hash = "sha256:ac394ff680fc46b97487941f5e6ae49a9f30ea41c6c6804832063f14b2a5a145"}, + {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f5b1dff3ad008dccf18e652283f5e5339d70bf8ba7c98bf848ac33db10f7bc7a"}, + {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6a2b494a76983df8e3d3feea9b9ffdd558b247e60b92f877f93a1ff43d26656"}, + {file = "regex-2024.5.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a32b96f15c8ab2e7d27655969a23895eb799de3665fa94349f3b2fbfd547236f"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10002e86e6068d9e1c91eae8295ef690f02f913c57db120b58fdd35a6bb1af35"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec54d5afa89c19c6dd8541a133be51ee1017a38b412b1321ccb8d6ddbeb4cf7d"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10e4ce0dca9ae7a66e6089bb29355d4432caed736acae36fef0fdd7879f0b0cb"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e507ff1e74373c4d3038195fdd2af30d297b4f0950eeda6f515ae3d84a1770f"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f059a4d795e646e1c37665b9d06062c62d0e8cc3c511fe01315973a6542e40"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0721931ad5fe0dda45d07f9820b90b2148ccdd8e45bb9e9b42a146cb4f695649"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:833616ddc75ad595dee848ad984d067f2f31be645d603e4d158bba656bbf516c"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:287eb7f54fc81546346207c533ad3c2c51a8d61075127d7f6d79aaf96cdee890"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:19dfb1c504781a136a80ecd1fff9f16dddf5bb43cec6871778c8a907a085bb3d"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:119af6e56dce35e8dfb5222573b50c89e5508d94d55713c75126b753f834de68"}, + {file = "regex-2024.5.15-cp311-cp311-win32.whl", hash = "sha256:1c1c174d6ec38d6c8a7504087358ce9213d4332f6293a94fbf5249992ba54efa"}, + {file = "regex-2024.5.15-cp311-cp311-win_amd64.whl", hash = "sha256:9e717956dcfd656f5055cc70996ee2cc82ac5149517fc8e1b60261b907740201"}, + {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:632b01153e5248c134007209b5c6348a544ce96c46005d8456de1d552455b014"}, + {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e64198f6b856d48192bf921421fdd8ad8eb35e179086e99e99f711957ffedd6e"}, + {file = "regex-2024.5.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68811ab14087b2f6e0fc0c2bae9ad689ea3584cad6917fc57be6a48bbd012c49"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ec0c2fea1e886a19c3bee0cd19d862b3aa75dcdfb42ebe8ed30708df64687a"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0c0c0003c10f54a591d220997dd27d953cd9ccc1a7294b40a4be5312be8797b"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2431b9e263af1953c55abbd3e2efca67ca80a3de8a0437cb58e2421f8184717a"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a605586358893b483976cffc1723fb0f83e526e8f14c6e6614e75919d9862cf"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391d7f7f1e409d192dba8bcd42d3e4cf9e598f3979cdaed6ab11288da88cb9f2"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9ff11639a8d98969c863d4617595eb5425fd12f7c5ef6621a4b74b71ed8726d5"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4eee78a04e6c67e8391edd4dad3279828dd66ac4b79570ec998e2155d2e59fd5"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8fe45aa3f4aa57faabbc9cb46a93363edd6197cbc43523daea044e9ff2fea83e"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0a3d8d6acf0c78a1fff0e210d224b821081330b8524e3e2bc5a68ef6ab5803d"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c486b4106066d502495b3025a0a7251bf37ea9540433940a23419461ab9f2a80"}, + {file = "regex-2024.5.15-cp312-cp312-win32.whl", hash = "sha256:c49e15eac7c149f3670b3e27f1f28a2c1ddeccd3a2812cba953e01be2ab9b5fe"}, + {file = "regex-2024.5.15-cp312-cp312-win_amd64.whl", hash = "sha256:673b5a6da4557b975c6c90198588181029c60793835ce02f497ea817ff647cb2"}, + {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:87e2a9c29e672fc65523fb47a90d429b70ef72b901b4e4b1bd42387caf0d6835"}, + {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3bea0ba8b73b71b37ac833a7f3fd53825924165da6a924aec78c13032f20850"}, + {file = "regex-2024.5.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bfc4f82cabe54f1e7f206fd3d30fda143f84a63fe7d64a81558d6e5f2e5aaba9"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5bb9425fe881d578aeca0b2b4b3d314ec88738706f66f219c194d67179337cb"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64c65783e96e563103d641760664125e91bd85d8e49566ee560ded4da0d3e704"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf2430df4148b08fb4324b848672514b1385ae3807651f3567871f130a728cc3"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5397de3219a8b08ae9540c48f602996aa6b0b65d5a61683e233af8605c42b0f2"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:455705d34b4154a80ead722f4f185b04c4237e8e8e33f265cd0798d0e44825fa"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2b6f1b3bb6f640c1a92be3bbfbcb18657b125b99ecf141fb3310b5282c7d4ed"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3ad070b823ca5890cab606c940522d05d3d22395d432f4aaaf9d5b1653e47ced"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5b5467acbfc153847d5adb21e21e29847bcb5870e65c94c9206d20eb4e99a384"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e6662686aeb633ad65be2a42b4cb00178b3fbf7b91878f9446075c404ada552f"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:2b4c884767504c0e2401babe8b5b7aea9148680d2e157fa28f01529d1f7fcf67"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3cd7874d57f13bf70078f1ff02b8b0aa48d5b9ed25fc48547516c6aba36f5741"}, + {file = "regex-2024.5.15-cp38-cp38-win32.whl", hash = "sha256:e4682f5ba31f475d58884045c1a97a860a007d44938c4c0895f41d64481edbc9"}, + {file = "regex-2024.5.15-cp38-cp38-win_amd64.whl", hash = "sha256:d99ceffa25ac45d150e30bd9ed14ec6039f2aad0ffa6bb87a5936f5782fc1569"}, + {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13cdaf31bed30a1e1c2453ef6015aa0983e1366fad2667657dbcac7b02f67133"}, + {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cac27dcaa821ca271855a32188aa61d12decb6fe45ffe3e722401fe61e323cd1"}, + {file = "regex-2024.5.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7dbe2467273b875ea2de38ded4eba86cbcbc9a1a6d0aa11dcf7bd2e67859c435"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f18a9a3513a99c4bef0e3efd4c4a5b11228b48aa80743be822b71e132ae4f5"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d347a741ea871c2e278fde6c48f85136c96b8659b632fb57a7d1ce1872547600"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1878b8301ed011704aea4c806a3cadbd76f84dece1ec09cc9e4dc934cfa5d4da"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4babf07ad476aaf7830d77000874d7611704a7fcf68c9c2ad151f5d94ae4bfc4"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35cb514e137cb3488bce23352af3e12fb0dbedd1ee6e60da053c69fb1b29cc6c"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cdd09d47c0b2efee9378679f8510ee6955d329424c659ab3c5e3a6edea696294"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:72d7a99cd6b8f958e85fc6ca5b37c4303294954eac1376535b03c2a43eb72629"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a094801d379ab20c2135529948cb84d417a2169b9bdceda2a36f5f10977ebc16"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c18345010870e58238790a6779a1219b4d97bd2e77e1140e8ee5d14df071aa"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:16093f563098448ff6b1fa68170e4acbef94e6b6a4e25e10eae8598bb1694b5d"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e38a7d4e8f633a33b4c7350fbd8bad3b70bf81439ac67ac38916c4a86b465456"}, + {file = "regex-2024.5.15-cp39-cp39-win32.whl", hash = "sha256:71a455a3c584a88f654b64feccc1e25876066c4f5ef26cd6dd711308aa538694"}, + {file = "regex-2024.5.15-cp39-cp39-win_amd64.whl", hash = "sha256:cab12877a9bdafde5500206d1020a584355a97884dfd388af3699e9137bf7388"}, + {file = "regex-2024.5.15.tar.gz", hash = "sha256:d3ee02d9e5f482cc8309134a91eeaacbdd2261ba111b0fef3748eeb4913e6a2c"}, ] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -2107,13 +2289,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "requests-file" -version = "2.0.0" +version = "2.1.0" description = "File transport adapter for Requests" optional = false python-versions = "*" files = [ - {file = "requests-file-2.0.0.tar.gz", hash = "sha256:20c5931629c558fda566cacc10cfe2cd502433e628f568c34c80d96a0cc95972"}, - {file = "requests_file-2.0.0-py2.py3-none-any.whl", hash = "sha256:3e493d390adb44aa102ebea827a48717336d5268968c370eaf19abaf5cae13bf"}, + {file = "requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c"}, + {file = "requests_file-2.1.0.tar.gz", hash = "sha256:0f549a3f3b0699415ac04d167e9cb39bccfb730cb832b4d20be3d9867356e658"}, ] [package.dependencies] @@ -2136,21 +2318,120 @@ lint = ["black", "flake8", "isort", "mypy", "types-requests"] release = ["build", "towncrier", "twine"] test = ["commentjson", "packaging", "pytest"] +[[package]] +name = "setproctitle" +version = "1.3.3" +description = "A Python module to customize the process title" +optional = false +python-versions = ">=3.7" +files = [ + {file = "setproctitle-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:897a73208da48db41e687225f355ce993167079eda1260ba5e13c4e53be7f754"}, + {file = "setproctitle-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c331e91a14ba4076f88c29c777ad6b58639530ed5b24b5564b5ed2fd7a95452"}, + {file = "setproctitle-1.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbbd6c7de0771c84b4aa30e70b409565eb1fc13627a723ca6be774ed6b9d9fa3"}, + {file = "setproctitle-1.3.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c05ac48ef16ee013b8a326c63e4610e2430dbec037ec5c5b58fcced550382b74"}, + {file = "setproctitle-1.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1342f4fdb37f89d3e3c1c0a59d6ddbedbde838fff5c51178a7982993d238fe4f"}, + {file = "setproctitle-1.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc74e84fdfa96821580fb5e9c0b0777c1c4779434ce16d3d62a9c4d8c710df39"}, + {file = "setproctitle-1.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9617b676b95adb412bb69645d5b077d664b6882bb0d37bfdafbbb1b999568d85"}, + {file = "setproctitle-1.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6a249415f5bb88b5e9e8c4db47f609e0bf0e20a75e8d744ea787f3092ba1f2d0"}, + {file = "setproctitle-1.3.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:38da436a0aaace9add67b999eb6abe4b84397edf4a78ec28f264e5b4c9d53cd5"}, + {file = "setproctitle-1.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:da0d57edd4c95bf221b2ebbaa061e65b1788f1544977288bdf95831b6e44e44d"}, + {file = "setproctitle-1.3.3-cp310-cp310-win32.whl", hash = "sha256:a1fcac43918b836ace25f69b1dca8c9395253ad8152b625064415b1d2f9be4fb"}, + {file = "setproctitle-1.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:200620c3b15388d7f3f97e0ae26599c0c378fdf07ae9ac5a13616e933cbd2086"}, + {file = "setproctitle-1.3.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:334f7ed39895d692f753a443102dd5fed180c571eb6a48b2a5b7f5b3564908c8"}, + {file = "setproctitle-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:950f6476d56ff7817a8fed4ab207727fc5260af83481b2a4b125f32844df513a"}, + {file = "setproctitle-1.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:195c961f54a09eb2acabbfc90c413955cf16c6e2f8caa2adbf2237d1019c7dd8"}, + {file = "setproctitle-1.3.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f05e66746bf9fe6a3397ec246fe481096664a9c97eb3fea6004735a4daf867fd"}, + {file = "setproctitle-1.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5901a31012a40ec913265b64e48c2a4059278d9f4e6be628441482dd13fb8b5"}, + {file = "setproctitle-1.3.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64286f8a995f2cd934082b398fc63fca7d5ffe31f0e27e75b3ca6b4efda4e353"}, + {file = "setproctitle-1.3.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:184239903bbc6b813b1a8fc86394dc6ca7d20e2ebe6f69f716bec301e4b0199d"}, + {file = "setproctitle-1.3.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:664698ae0013f986118064b6676d7dcd28fefd0d7d5a5ae9497cbc10cba48fa5"}, + {file = "setproctitle-1.3.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e5119a211c2e98ff18b9908ba62a3bd0e3fabb02a29277a7232a6fb4b2560aa0"}, + {file = "setproctitle-1.3.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:417de6b2e214e837827067048f61841f5d7fc27926f2e43954567094051aff18"}, + {file = "setproctitle-1.3.3-cp311-cp311-win32.whl", hash = "sha256:6a143b31d758296dc2f440175f6c8e0b5301ced3b0f477b84ca43cdcf7f2f476"}, + {file = "setproctitle-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:a680d62c399fa4b44899094027ec9a1bdaf6f31c650e44183b50d4c4d0ccc085"}, + {file = "setproctitle-1.3.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d4460795a8a7a391e3567b902ec5bdf6c60a47d791c3b1d27080fc203d11c9dc"}, + {file = "setproctitle-1.3.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bdfd7254745bb737ca1384dee57e6523651892f0ea2a7344490e9caefcc35e64"}, + {file = "setproctitle-1.3.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:477d3da48e216d7fc04bddab67b0dcde633e19f484a146fd2a34bb0e9dbb4a1e"}, + {file = "setproctitle-1.3.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ab2900d111e93aff5df9fddc64cf51ca4ef2c9f98702ce26524f1acc5a786ae7"}, + {file = "setproctitle-1.3.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:088b9efc62d5aa5d6edf6cba1cf0c81f4488b5ce1c0342a8b67ae39d64001120"}, + {file = "setproctitle-1.3.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6d50252377db62d6a0bb82cc898089916457f2db2041e1d03ce7fadd4a07381"}, + {file = "setproctitle-1.3.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:87e668f9561fd3a457ba189edfc9e37709261287b52293c115ae3487a24b92f6"}, + {file = "setproctitle-1.3.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:287490eb90e7a0ddd22e74c89a92cc922389daa95babc833c08cf80c84c4df0a"}, + {file = "setproctitle-1.3.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:4fe1c49486109f72d502f8be569972e27f385fe632bd8895f4730df3c87d5ac8"}, + {file = "setproctitle-1.3.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4a6ba2494a6449b1f477bd3e67935c2b7b0274f2f6dcd0f7c6aceae10c6c6ba3"}, + {file = "setproctitle-1.3.3-cp312-cp312-win32.whl", hash = "sha256:2df2b67e4b1d7498632e18c56722851ba4db5d6a0c91aaf0fd395111e51cdcf4"}, + {file = "setproctitle-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:f38d48abc121263f3b62943f84cbaede05749047e428409c2c199664feb6abc7"}, + {file = "setproctitle-1.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:816330675e3504ae4d9a2185c46b573105d2310c20b19ea2b4596a9460a4f674"}, + {file = "setproctitle-1.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68f960bc22d8d8e4ac886d1e2e21ccbd283adcf3c43136161c1ba0fa509088e0"}, + {file = "setproctitle-1.3.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e6e7adff74796ef12753ff399491b8827f84f6c77659d71bd0b35870a17d8f"}, + {file = "setproctitle-1.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53bc0d2358507596c22b02db079618451f3bd720755d88e3cccd840bafb4c41c"}, + {file = "setproctitle-1.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad6d20f9541f5f6ac63df553b6d7a04f313947f550eab6a61aa758b45f0d5657"}, + {file = "setproctitle-1.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c1c84beab776b0becaa368254801e57692ed749d935469ac10e2b9b825dbdd8e"}, + {file = "setproctitle-1.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:507e8dc2891021350eaea40a44ddd887c9f006e6b599af8d64a505c0f718f170"}, + {file = "setproctitle-1.3.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b1067647ac7aba0b44b591936118a22847bda3c507b0a42d74272256a7a798e9"}, + {file = "setproctitle-1.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2e71f6365744bf53714e8bd2522b3c9c1d83f52ffa6324bd7cbb4da707312cd8"}, + {file = "setproctitle-1.3.3-cp37-cp37m-win32.whl", hash = "sha256:7f1d36a1e15a46e8ede4e953abb104fdbc0845a266ec0e99cc0492a4364f8c44"}, + {file = "setproctitle-1.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c9a402881ec269d0cc9c354b149fc29f9ec1a1939a777f1c858cdb09c7a261df"}, + {file = "setproctitle-1.3.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ff814dea1e5c492a4980e3e7d094286077054e7ea116cbeda138819db194b2cd"}, + {file = "setproctitle-1.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:accb66d7b3ccb00d5cd11d8c6e07055a4568a24c95cf86109894dcc0c134cc89"}, + {file = "setproctitle-1.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554eae5a5b28f02705b83a230e9d163d645c9a08914c0ad921df363a07cf39b1"}, + {file = "setproctitle-1.3.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a911b26264dbe9e8066c7531c0591cfab27b464459c74385b276fe487ca91c12"}, + {file = "setproctitle-1.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2982efe7640c4835f7355fdb4da313ad37fb3b40f5c69069912f8048f77b28c8"}, + {file = "setproctitle-1.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df3f4274b80709d8bcab2f9a862973d453b308b97a0b423a501bcd93582852e3"}, + {file = "setproctitle-1.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:af2c67ae4c795d1674a8d3ac1988676fa306bcfa1e23fddb5e0bd5f5635309ca"}, + {file = "setproctitle-1.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:af4061f67fd7ec01624c5e3c21f6b7af2ef0e6bab7fbb43f209e6506c9ce0092"}, + {file = "setproctitle-1.3.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:37a62cbe16d4c6294e84670b59cf7adcc73faafe6af07f8cb9adaf1f0e775b19"}, + {file = "setproctitle-1.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a83ca086fbb017f0d87f240a8f9bbcf0809f3b754ee01cec928fff926542c450"}, + {file = "setproctitle-1.3.3-cp38-cp38-win32.whl", hash = "sha256:059f4ce86f8cc92e5860abfc43a1dceb21137b26a02373618d88f6b4b86ba9b2"}, + {file = "setproctitle-1.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:ab92e51cd4a218208efee4c6d37db7368fdf182f6e7ff148fb295ecddf264287"}, + {file = "setproctitle-1.3.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c7951820b77abe03d88b114b998867c0f99da03859e5ab2623d94690848d3e45"}, + {file = "setproctitle-1.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5bc94cf128676e8fac6503b37763adb378e2b6be1249d207630f83fc325d9b11"}, + {file = "setproctitle-1.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f5d9027eeda64d353cf21a3ceb74bb1760bd534526c9214e19f052424b37e42"}, + {file = "setproctitle-1.3.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e4a8104db15d3462e29d9946f26bed817a5b1d7a47eabca2d9dc2b995991503"}, + {file = "setproctitle-1.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c32c41ace41f344d317399efff4cffb133e709cec2ef09c99e7a13e9f3b9483c"}, + {file = "setproctitle-1.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbf16381c7bf7f963b58fb4daaa65684e10966ee14d26f5cc90f07049bfd8c1e"}, + {file = "setproctitle-1.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e18b7bd0898398cc97ce2dfc83bb192a13a087ef6b2d5a8a36460311cb09e775"}, + {file = "setproctitle-1.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:69d565d20efe527bd8a9b92e7f299ae5e73b6c0470f3719bd66f3cd821e0d5bd"}, + {file = "setproctitle-1.3.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ddedd300cd690a3b06e7eac90ed4452348b1348635777ce23d460d913b5b63c3"}, + {file = "setproctitle-1.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:415bfcfd01d1fbf5cbd75004599ef167a533395955305f42220a585f64036081"}, + {file = "setproctitle-1.3.3-cp39-cp39-win32.whl", hash = "sha256:21112fcd2195d48f25760f0eafa7a76510871bbb3b750219310cf88b04456ae3"}, + {file = "setproctitle-1.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:5a740f05d0968a5a17da3d676ce6afefebeeeb5ce137510901bf6306ba8ee002"}, + {file = "setproctitle-1.3.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6b9e62ddb3db4b5205c0321dd69a406d8af9ee1693529d144e86bd43bcb4b6c0"}, + {file = "setproctitle-1.3.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e3b99b338598de0bd6b2643bf8c343cf5ff70db3627af3ca427a5e1a1a90dd9"}, + {file = "setproctitle-1.3.3-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ae9a02766dad331deb06855fb7a6ca15daea333b3967e214de12cfae8f0ef5"}, + {file = "setproctitle-1.3.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:200ede6fd11233085ba9b764eb055a2a191fb4ffb950c68675ac53c874c22e20"}, + {file = "setproctitle-1.3.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0d3a953c50776751e80fe755a380a64cb14d61e8762bd43041ab3f8cc436092f"}, + {file = "setproctitle-1.3.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5e08e232b78ba3ac6bc0d23ce9e2bee8fad2be391b7e2da834fc9a45129eb87"}, + {file = "setproctitle-1.3.3-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1da82c3e11284da4fcbf54957dafbf0655d2389cd3d54e4eaba636faf6d117a"}, + {file = "setproctitle-1.3.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:aeaa71fb9568ebe9b911ddb490c644fbd2006e8c940f21cb9a1e9425bd709574"}, + {file = "setproctitle-1.3.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:59335d000c6250c35989394661eb6287187854e94ac79ea22315469ee4f4c244"}, + {file = "setproctitle-1.3.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3ba57029c9c50ecaf0c92bb127224cc2ea9fda057b5d99d3f348c9ec2855ad3"}, + {file = "setproctitle-1.3.3-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d876d355c53d975c2ef9c4f2487c8f83dad6aeaaee1b6571453cb0ee992f55f6"}, + {file = "setproctitle-1.3.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:224602f0939e6fb9d5dd881be1229d485f3257b540f8a900d4271a2c2aa4e5f4"}, + {file = "setproctitle-1.3.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d7f27e0268af2d7503386e0e6be87fb9b6657afd96f5726b733837121146750d"}, + {file = "setproctitle-1.3.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5e7266498cd31a4572378c61920af9f6b4676a73c299fce8ba93afd694f8ae7"}, + {file = "setproctitle-1.3.3-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33c5609ad51cd99d388e55651b19148ea99727516132fb44680e1f28dd0d1de9"}, + {file = "setproctitle-1.3.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:eae8988e78192fd1a3245a6f4f382390b61bce6cfcc93f3809726e4c885fa68d"}, + {file = "setproctitle-1.3.3.tar.gz", hash = "sha256:c913e151e7ea01567837ff037a23ca8740192880198b7fbb90b16d181607caae"}, +] + +[package.extras] +test = ["pytest"] + [[package]] name = "setuptools" -version = "69.5.1" +version = "70.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, + {file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"}, + {file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -2244,44 +2525,44 @@ files = [ [[package]] name = "tomlkit" -version = "0.12.4" +version = "0.12.5" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" files = [ - {file = "tomlkit-0.12.4-py3-none-any.whl", hash = "sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b"}, - {file = "tomlkit-0.12.4.tar.gz", hash = "sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3"}, + {file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"}, + {file = "tomlkit-0.12.5.tar.gz", hash = "sha256:eef34fba39834d4d6b73c9ba7f3e4d1c417a4e56f89a7e96e090dd0d24b8fb3c"}, ] [[package]] name = "tornado" -version = "6.4" +version = "6.4.1" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false -python-versions = ">= 3.8" +python-versions = ">=3.8" files = [ - {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, - {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"}, - {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"}, - {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"}, - {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"}, + {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"}, + {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"}, + {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"}, ] [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -2314,13 +2595,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.25.3" +version = "20.26.2" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.3-py3-none-any.whl", hash = "sha256:8aac4332f2ea6ef519c648d0bc48a5b1d324994753519919bddbb1aff25a104e"}, - {file = "virtualenv-20.25.3.tar.gz", hash = "sha256:7bb554bbdfeaacc3349fa614ea5bff6ac300fc7c335e9facf3a3bcfc703f45be"}, + {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"}, + {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"}, ] [package.dependencies] @@ -2334,40 +2615,43 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "watchdog" -version = "4.0.0" +version = "4.0.1" description = "Filesystem events monitoring" optional = false python-versions = ">=3.8" files = [ - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, - {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, - {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, - {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, - {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, - {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, - {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, - {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, + {file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:da2dfdaa8006eb6a71051795856bedd97e5b03e57da96f98e375682c48850645"}, + {file = "watchdog-4.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e93f451f2dfa433d97765ca2634628b789b49ba8b504fdde5837cdcf25fdb53b"}, + {file = "watchdog-4.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ef0107bbb6a55f5be727cfc2ef945d5676b97bffb8425650dadbb184be9f9a2b"}, + {file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17e32f147d8bf9657e0922c0940bcde863b894cd871dbb694beb6704cfbd2fb5"}, + {file = "watchdog-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03e70d2df2258fb6cb0e95bbdbe06c16e608af94a3ffbd2b90c3f1e83eb10767"}, + {file = "watchdog-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123587af84260c991dc5f62a6e7ef3d1c57dfddc99faacee508c71d287248459"}, + {file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:093b23e6906a8b97051191a4a0c73a77ecc958121d42346274c6af6520dec175"}, + {file = "watchdog-4.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:611be3904f9843f0529c35a3ff3fd617449463cb4b73b1633950b3d97fa4bfb7"}, + {file = "watchdog-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:62c613ad689ddcb11707f030e722fa929f322ef7e4f18f5335d2b73c61a85c28"}, + {file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d4925e4bf7b9bddd1c3de13c9b8a2cdb89a468f640e66fbfabaf735bd85b3e35"}, + {file = "watchdog-4.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cad0bbd66cd59fc474b4a4376bc5ac3fc698723510cbb64091c2a793b18654db"}, + {file = "watchdog-4.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a3c2c317a8fb53e5b3d25790553796105501a235343f5d2bf23bb8649c2c8709"}, + {file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9904904b6564d4ee8a1ed820db76185a3c96e05560c776c79a6ce5ab71888ba"}, + {file = "watchdog-4.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:667f3c579e813fcbad1b784db7a1aaa96524bed53437e119f6a2f5de4db04235"}, + {file = "watchdog-4.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d10a681c9a1d5a77e75c48a3b8e1a9f2ae2928eda463e8d33660437705659682"}, + {file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0144c0ea9997b92615af1d94afc0c217e07ce2c14912c7b1a5731776329fcfc7"}, + {file = "watchdog-4.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:998d2be6976a0ee3a81fb8e2777900c28641fb5bfbd0c84717d89bca0addcdc5"}, + {file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e7921319fe4430b11278d924ef66d4daa469fafb1da679a2e48c935fa27af193"}, + {file = "watchdog-4.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f0de0f284248ab40188f23380b03b59126d1479cd59940f2a34f8852db710625"}, + {file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bca36be5707e81b9e6ce3208d92d95540d4ca244c006b61511753583c81c70dd"}, + {file = "watchdog-4.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab998f567ebdf6b1da7dc1e5accfaa7c6992244629c0fdaef062f43249bd8dee"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dddba7ca1c807045323b6af4ff80f5ddc4d654c8bce8317dde1bd96b128ed253"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:4513ec234c68b14d4161440e07f995f231be21a09329051e67a2118a7a612d2d"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_i686.whl", hash = "sha256:4107ac5ab936a63952dea2a46a734a23230aa2f6f9db1291bf171dac3ebd53c6"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:6e8c70d2cd745daec2a08734d9f63092b793ad97612470a0ee4cbb8f5f705c57"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f27279d060e2ab24c0aa98363ff906d2386aa6c4dc2f1a374655d4e02a6c5e5e"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:f8affdf3c0f0466e69f5b3917cdd042f89c8c63aebdb9f7c078996f607cdb0f5"}, + {file = "watchdog-4.0.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ac7041b385f04c047fcc2951dc001671dee1b7e0615cde772e84b01fbf68ee84"}, + {file = "watchdog-4.0.1-py3-none-win32.whl", hash = "sha256:206afc3d964f9a233e6ad34618ec60b9837d0582b500b63687e34011e15bb429"}, + {file = "watchdog-4.0.1-py3-none-win_amd64.whl", hash = "sha256:7577b3c43e5909623149f76b099ac49a1a01ca4e167d1785c76eb52fa585745a"}, + {file = "watchdog-4.0.1-py3-none-win_ia64.whl", hash = "sha256:d7b9f5f3299e8dd230880b6c55504a1f69cf1e4316275d1b215ebdd8187ec88d"}, + {file = "watchdog-4.0.1.tar.gz", hash = "sha256:eebaacf674fa25511e8867028d281e602ee6500045b57f43b08778082f7f8b44"}, ] [package.extras] @@ -2506,22 +2790,111 @@ files = [ [package.dependencies] xmltodict = ">=0.12.0,<0.13.0" +[[package]] +name = "yara-python" +version = "4.5.1" +description = "Python interface for YARA" +optional = false +python-versions = "*" +files = [ + {file = "yara_python-4.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c92219bf91caea277bc2736df70dda3709834c297a4a5906f1d9a46cd03579a"}, + {file = "yara_python-4.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e8e9eb5a49a70a013bf45e0ec97210b7cb124813271fddc666c3cfb1308a2d5"}, + {file = "yara_python-4.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffb48e853f107f2e6e0e29a97ce1185e9cc7a15a6c860dc65eb8ec431d1b6d3e"}, + {file = "yara_python-4.5.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6a4e181de457a5de74982b82ab01c89a06bcd66820ca1671f22e984be1be78"}, + {file = "yara_python-4.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:155ef1a9ca2aeeb57441fa99b6d8bd2cb67787f0d62b3c1670512e36c97ec02f"}, + {file = "yara_python-4.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:264fdc2953c635131112a2cef6208b52d35731a6cc902cc62fe82508d9051afd"}, + {file = "yara_python-4.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1a3e6b610e7131353cfea80ba119db3e96f7ad7befcd9d5a51df8786c806403"}, + {file = "yara_python-4.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aec3dda6b173c4be4d972058ee41fb019c866b82861f12a1ac2b01035cea34b9"}, + {file = "yara_python-4.5.1-cp310-cp310-win32.whl", hash = "sha256:8c3935da45ce283e02a86c9120240524e352add64c5cbccd616885937801ac67"}, + {file = "yara_python-4.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:59fd46cc8c5a77e5e4942c7e403ac738f5c64154dcbc67bd8c9af453d7bb2539"}, + {file = "yara_python-4.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3044359876921e26370f7b646d84a65681811df577be7d4d09c7de21b33d9130"}, + {file = "yara_python-4.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ad70b6b65ed1c591c3bfb3d5d6da0fc6a73b1f979604feead450f348ad67c4"}, + {file = "yara_python-4.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6a185d2ec8fbbffa89d0f7949b84f76860d0e3a74192825dbf53d6a5069b83"}, + {file = "yara_python-4.5.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2560dd27f63cdb395d9d77d6a74d1f0d6b7aa0ea18394f44d650e5abb6e377a3"}, + {file = "yara_python-4.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:471e4070bf7e3b9b132f1c0134d1172d9dae353b04f2fce9bc31431ae785595e"}, + {file = "yara_python-4.5.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f533848781f0e46e44eda77055eae4ec934cf56c1f473e787704f1a348e90094"}, + {file = "yara_python-4.5.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3aaf259ed162d2de5db70ae1ba057307efdeb7f4697d74cc5b3313caa7647923"}, + {file = "yara_python-4.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:90374acc38086447a668580a9aecceb11964f08deb05bfaced6f43e9e67955a1"}, + {file = "yara_python-4.5.1-cp311-cp311-win32.whl", hash = "sha256:721422a14d18a81d75397df51481f5b5f3ab8d0a5220087e5306570877cab4e4"}, + {file = "yara_python-4.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:dac13dc77a5f21c119104ae4e6ad837589eace0505e9daf38af0bd2d4ccd7cfa"}, + {file = "yara_python-4.5.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7eb27c1cd2f6f93f68e23e676ede28357c1fc8b9ec7deefe86f2cfef4abd877c"}, + {file = "yara_python-4.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4c7ac7c1ae5e25bd5bf67ce752ac82568c2cdc157c9af50ba28d7cbab4421175"}, + {file = "yara_python-4.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77011bed905f3786755da7de7ba9082790db654a241e13746fa3fc325b9ad966"}, + {file = "yara_python-4.5.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ddedd9bfcfc37ffddceefd9dbf9bbba137c979b3effc9c1e9aeb08d77c6858c"}, + {file = "yara_python-4.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3431154fac7f41b4657edad91632717b5f1bab5be4ed6ce28d6e17e441d5c947"}, + {file = "yara_python-4.5.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7d5dc091235ded00b30f04a51d70e08352e44976122f8d45e63d25e96eae27d9"}, + {file = "yara_python-4.5.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:97d30a483d195e6b695f072086cf1234317a650727844bac7bf85cf98dd960a3"}, + {file = "yara_python-4.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bb65c17657b4cdbe5adee7a6e617ee05e214e8afdbc82b195885354a72a16476"}, + {file = "yara_python-4.5.1-cp312-cp312-win32.whl", hash = "sha256:4f368d057e0865278444c948a65802f7c92008a1b59bf629bdc9efa1b0120a22"}, + {file = "yara_python-4.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ccd73466d7ad1a50cd06f38fdb7a023fee87dd185d3fcf67cc5c55d82cc34dd"}, + {file = "yara_python-4.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37ff0e6256d75521e5ac52b45671647bd6f6a7aa49259b13c19db424d9fdb795"}, + {file = "yara_python-4.5.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c17d1555dbd99f4872ca289ee92b9630331def0df864f88ced1665efa3cabdac"}, + {file = "yara_python-4.5.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfae9eac6a65d25799aecd21cb43f3552a86552c57e90e85e03a1e95e100fb35"}, + {file = "yara_python-4.5.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8c8cfbdc33cbcf78afd6e11149e406dfe558bbd497ff0c9b001753545a326e7"}, + {file = "yara_python-4.5.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:bb767f5c9c67d0b5de4d916c92130303d02d07d5a96a160aa5d7aa6c45883b1f"}, + {file = "yara_python-4.5.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e14d43aba8a8d66268cd45ce534bb7b608ca08d97d4ffb9f0205ef5554e317fb"}, + {file = "yara_python-4.5.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4c2d81727e24c224b0003770f2548f2eb75d9a95d5aa03b65d5ccf8ab3112d8d"}, + {file = "yara_python-4.5.1-cp37-cp37m-win32.whl", hash = "sha256:da5848e64fdde37529e6ebd8e5778e4665a7dee8cdff2f347ec47a39b453f298"}, + {file = "yara_python-4.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0fc8a450b662a0235ab7cee59ad2e366207c97bb99a80db9ffb68f865abd4ac9"}, + {file = "yara_python-4.5.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0324175b06c440eb754b7ff3845b6eb426b5870bbbebbeae32f2e5281fd35860"}, + {file = "yara_python-4.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f408668aab84a0f42b78784d948a69a99bf95300536edd4ab771bb4a06d92f50"}, + {file = "yara_python-4.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a885ec2800b3ee8c4ba9e6634005e041afad33998d59fa6c76bea60c1bd9c73b"}, + {file = "yara_python-4.5.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:153d459a2382a28d08edb84a74f27d8ef2cc8154f7822dadf744c5797e8e6f25"}, + {file = "yara_python-4.5.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:509ca2000c9f76c3304f9fdbb886b1d403231a6a76ec9b4aeb18c67ee8279917"}, + {file = "yara_python-4.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b03d2ffe24a13d69d14b12517aac7a4ea5f0df41ac725f282ebdc729f4365a3d"}, + {file = "yara_python-4.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8e90cc9bee1340dec0e9dab95e056dec08e6ac67945ad20f537d65457845f2f1"}, + {file = "yara_python-4.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f6e85ee2fe458b52d4984bc2327cd33d69a10579dd708e29d6fbd371aceafe"}, + {file = "yara_python-4.5.1-cp38-cp38-win32.whl", hash = "sha256:90aa56a3e27fdc5751550fe136a8d815c55a1a1db025b28d1f7d146493751310"}, + {file = "yara_python-4.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:4cc7d5220a488fa0470f7c7ea303d1174e3b7e88dc6eef539ab048c8590257a8"}, + {file = "yara_python-4.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6e8566034b9c24a12a8fd8b0ff580b078add7f9e9719e633ad1adcbb33be534a"}, + {file = "yara_python-4.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:934f08ca197a645977749ca1163262abcec9bdbcb54cd47ffb2452c3edc4c5e4"}, + {file = "yara_python-4.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a41992c45fcad39ad05016eafc3c3632b3a11ede2440ba9c1250c5e5d484687a"}, + {file = "yara_python-4.5.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70eb3f84b6e57f7f52676ae9c11dccde2867f49bac6e9a042ef2d027a8afb9f1"}, + {file = "yara_python-4.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d21efeb69d83c48419beccda4aeb415c4c993387e6dee64d8eac4b33af8ac58"}, + {file = "yara_python-4.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:98b780fe880cb219b9a92957a1f9863e53908a2dd75483976265d256b3b69b84"}, + {file = "yara_python-4.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:04c414472b0e3c4a2998ae247c0215bbb52c7808d09a7ca3899ef86ad1df7a7b"}, + {file = "yara_python-4.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0502328eeb18aa6e50af7e31df91b1dd23db0d47a0744383d90ff5cb38ff8d30"}, + {file = "yara_python-4.5.1-cp39-cp39-win32.whl", hash = "sha256:5c266ce1a9f6f783f565d0687a052e0a76c287495452a92d495809f8f6c32a44"}, + {file = "yara_python-4.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:cc08a46630373bf194dc560e422622d45a3cbefec334650a96777f4c5f31f637"}, + {file = "yara_python-4.5.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f23ea9893efd676eb2727e869b486d71e7cb7839789a36c80b726258365b39b6"}, + {file = "yara_python-4.5.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edf490994334b00933f7bc37fdd255451f12db741b15c2917fceb31e11bb698d"}, + {file = "yara_python-4.5.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:038dcec1728233144ab0ab7ea4ed060f642c5f3152742c9ee71b493f571d6fd5"}, + {file = "yara_python-4.5.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146f2fbdeb043c32a6a7d08a4e37a0bb1c3c0a16d2ad97d957627f6158360569"}, + {file = "yara_python-4.5.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:389aa3a655c94885399e290bd74703273d7a1ecb33593b62801abee91efdfc86"}, + {file = "yara_python-4.5.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:df67822be9430066f76604421f79b8d1446d749d925376c82c3e7649064899e3"}, + {file = "yara_python-4.5.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd0fa98a66e58be6a1d679e8679fc39029a4afa66d5310943d9180b90e57baf"}, + {file = "yara_python-4.5.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a073a26d1b081942fc741da8eeefe59c6fec5bf7f2adb3e80df1d73f57a7ea3"}, + {file = "yara_python-4.5.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92af5596aa4af20d7f81260dc72b989dfd4b7672c5492f13e9b71fe2b24c936f"}, + {file = "yara_python-4.5.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:984f67c08f945acb78d2548aaf5ffa19d27288b48979eb0652dd3a89c7b7747b"}, + {file = "yara_python-4.5.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2756fe1121fdd45b29d0d21fea66f762ef50d9e636bae8fd94217f0dc4c32a3a"}, + {file = "yara_python-4.5.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c27dd8bdf1bbd946a82d1717c3dcc2efa449abb04018d186dca6b412ed93eba6"}, + {file = "yara_python-4.5.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:382fd997999cfd83d7c2087f8b73c55dde8193473ff2a78643b5c69d3a39e084"}, + {file = "yara_python-4.5.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:024c477f182c26265fc447051e09099016e3562ac7f2255e05de2a506dd4d6dc"}, + {file = "yara_python-4.5.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2add91c1f2c7c6bd82affffd864f7e7a96285c80b97906f81584be3b3b448b74"}, + {file = "yara_python-4.5.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ae8411ae68a9f8911781bdc4393fc21ab48372ed3605c64265d08d57394ff5f"}, + {file = "yara_python-4.5.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc81d88d3fa54f2a019e716f715a18e0c2c7c03816fef926b07b4ab3ba698e69"}, + {file = "yara_python-4.5.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8765e387652f9354ca705ea8692e5e24424f7c20aaec857b40c13b18fe7862ad"}, + {file = "yara_python-4.5.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1acc3fd1b4634a4b438b6129f3b52a306d40e44c7fd950e7154f147a12e4de"}, + {file = "yara_python-4.5.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d64e300925d56b3cf7f430b3bf86e133b14aaf578cfe827c08aec8869b8375e9"}, + {file = "yara_python-4.5.1.tar.gz", hash = "sha256:52ab24422b021ae648be3de25090cbf9e6c6caa20488f498860d07f7be397930"}, +] + [[package]] name = "zipp" -version = "3.18.1" +version = "3.19.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, - {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, + {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"}, + {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "e4f9f370977b072794f49e3b9ac6fb383eb633b839d62693c02ef05494141b93" +content-hash = "5e35b19cbdc13561812068748aa482fed7a1255e366001c48fc56a0132e7818e" diff --git a/pyproject.toml b/pyproject.toml index e9e7f2af1..d77618683 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bbot" -version = "1.0.0" +version = "2.0.0" description = "OSINT automation for hackers." authors = [ "TheTechromancer", @@ -43,11 +43,18 @@ lxml = ">=4.9.2,<6.0.0" dnspython = "^2.4.2" pydantic = "^2.4.2" httpx = "^0.26.0" -cloudcheck = ">=2.1.0.181,<4.0.0.0" tldextract = "^5.1.1" cachetools = "^5.3.2" socksio = "^1.0.0" +jinja2 = "^3.1.3" +pyzmq = "^25.1.2" +regex = "^2024.4.16" unidecode = "^1.3.8" +radixtarget = "^1.0.0.15" +cloudcheck = "^5.0.0.350" +mmh3 = "^4.1.0" +setproctitle = "^1.3.3" +yara-python = "^4.5.1" [tool.poetry.group.dev.dependencies] flake8 = ">=6,<8" @@ -91,7 +98,7 @@ extend-exclude = "(test_step_1/test_manager_*)" [tool.poetry-dynamic-versioning] enable = true metadata = false -format-jinja = 'v1.1.9{% if branch == "dev" %}.{{ distance }}rc{% endif %}' +format-jinja = 'v2.0.0{% if branch == "dev" %}.{{ distance }}rc{% endif %}' [tool.poetry-dynamic-versioning.substitution] files = ["*/__init__.py"]