bbot Agent Skills & AI Rules

GitHub

The recursive internet scanner for hackers. ๐Ÿงก

10,318 stars Python Python 1 file ยท ~4,894 tokens #asm#attack-surface-management#automation#bugbounty#cli#easm#hacking#neo4j
RAW Rules
2 Discovered Rules โ€ข ~7,631 Estimated Tokens
Quick:
Loading skill content...

AI Agent Protocol & Instructions

AGENTS.md root-instruction ~7,614 tokens
Raw
# BBOT Developer Guide

## Core Principles

### Modularity Principle
When writing a BBOT module, make sure all module-specific code lives in the module itself. Don't hard-code module-specific things in core or in helpers.

### DRY Principle
Don't Repeat Yourself -- and interpret this broadly. If two pieces of code aren't identical but follow a similar enough pattern that they could be generalized, they should be. Extract shared logic into a common abstraction rather than duplicating the pattern. Usually this means creating a shared helper, or a shared module template in `bbot/modules/templates`. When you notice structural similarity, unify it.

### Engineering Principle
Every system that is implemented must be implemented properly. No hacks, no hardcoding, no shortcuts. If we implement one of something, we build a proper system for it. It's okay to take a step back from the current task, in order to do things right. This relates directly to the Modularity Principle above.

### Testing Principle
BBOT has extremely thorough tests, including **one or more individual tests for each module, with no exceptions**. This is critical to maintaining stability in a recursive tool, which by its nature flirts with race conditions and infinite loops. If you add a module, you write a test. If you change a module, you make sure its test still passes.

---

## Tooling

- **Package manager**: [uv](https://docs.astral.sh/uv/)
- **Linter/formatter**: [ruff](https://docs.astral.sh/ruff/) (pinned to 0.15.10)
- **Test framework**: [pytest](https://docs.pytest.org/) with pytest-asyncio
- **Python**: 3.10 - 3.14

---

## Dev Environment Setup

```bash
# 1. Fork and clone
git clone [email protected]:<you>/bbot.git
cd bbot

# 2. Switch to dev branch, then create a feature branch
git checkout dev
git checkout -b my-feature

# 3. Install uv (if you haven't already)
curl -LsSf https://astral.sh/uv/install.sh | sh

# 4. Install all dependencies (including dev)
uv sync --group dev

# 5. Install pre-commit hooks (ruff, file checks, etc.)
uv run pre-commit install

# 6. Activate the virtualenv
source .venv/bin/activate

# 7. Verify
bbot --help
```

### Running Tests

```bash
# Run the full suite
./bbot/test/run_tests.sh

# Run specific module tests
./bbot/test/run_tests.sh robots,sslcert

# Run a single test file directly
pytest bbot/test/test_step_2/module_tests/test_module_robots.py -x -vv
```

### Linting

```bash
ruff check          # lint
ruff format          # auto-format
ruff format --check  # verify formatting without changes
```

### Git Workflow

- `stable` - production releases
- `dev` - active development, **almost all PRs should target this branch**
- Feature branches should be created from `dev`

---

## AI Use Disclosure

Use of AI is not prohibited -- and in many cases, encouraged. However, when reviewing a PR, it is helpful for the reviewer to know the extent to which AI was used, and which model.
Please add a small section at the bottom of the PR with the header: `### AI Use Disclosure`, followed by the following information:

* Extent of the AI use. For example, was this fully autonomous by the AI, or was it a collaborative back-and-forth, or did the user just use the AI to review their work, etc.
* Model Used

This should only apply to external contributors, not members of the blacklanternsecurity organization.

---

## Architecture Overview

### How a Scan Works

BBOT is an async, recursive OSINT tool. A scan starts with **seed events** (targets) and passes them through a pipeline of **modules**. Each module watches for specific event types, processes them, and may emit new events, which feed back into the pipeline. This continues until no module has anything left to do.

```
                     Seeds (targets)
                          |
                          v
                   +--------------+
                   | ScanIngress  |  dedup, blacklist, scope check
                   +--------------+
                          |
                          v
                   +--------------+
                   |  Intercept   |  dns, cloud tagging
                   |   Modules    |  (modify/tag events before distribution)
                   +--------------+
                          |
                          v
                   +--------------+
                   |  ScanEgress  |  scope filtering, graph management
                   +--------------+
                          |
                          v
              +-----------+-----------+
              |           |           |
           Module A    Module B    Module C   ...
              |           |           |
              +-----------+-----------+
                          |
                          v
                   Output Modules (json, csv, neo4j, ...)
```

### Events

Events are the currency of BBOT. Every piece of data -- a hostname, IP, URL, open port, finding -- is an event. Events have:

- **type**: `DNS_NAME`, `IP_ADDRESS`, `URL`, `OPEN_TCP_PORT`, `HTTP_RESPONSE`, `FINDING`, `EMAIL_ADDRESS`, etc.
- **data**: the actual data (a string, dict, etc.)
- **parent**: the event that led to this one (forming a discovery chain)
- **scope_distance**: how many hops from the original target (0 = in-scope)
- **tags**: metadata like `in-scope`, `affiliate`, `cloud-azure`, `open-port`, etc.
- **module**: which module discovered it

### Scope Distance

Scope distance tracks how far an event is from the original target:
- `0` = explicitly in-scope (matches target or discovered in-scope)
- `1` = one hop away (e.g. a hostname found in an SSL cert of an in-scope host)
- `2+` = further away

The scan's `scope.search_distance` (default 0) controls how far modules are allowed to look. A module's `scope_distance_modifier` adjusts this per-module.

### Helpers

BBOT has a helper for almost everything. **Please use them.** They're accessible via `self.helpers` inside any module.

Key helpers:

| Helper | What it does |
|--------|-------------|
| `self.helpers.request(url)` | Make an HTTP request (with retries, SSL handling, etc.) |
| `self.helpers.blasthttp` | Shared blasthttp client (rate-limited via `web.http_rate_limit` config) |
| `self.helpers.resolve(host)` | DNS resolution |
| `self.helpers.is_ip(s)` | Check if string is an IP |
| `self.helpers.is_dns_name(s)` | Check if string is a hostname |
| `self.helpers.split_domain(host)` | Split into subdomain + root domain |
| `self.helpers.domain_parents(domain)` | Get all parent domains |
| `self.helpers.make_netloc(host, port)` | Format `host:port` (handles IPv6) |
| `self.helpers.parent_domain(domain)` | Get immediate parent domain |
| `self.helpers.beautifulsoup(html, parser)` | Parse HTML |
| `self.helpers.validators.validate_host(h)` | Validate and normalize a hostname |
| `self.helpers.tempfile(data, pipe=False)` | Create a temp file with content |
| `self.helpers.run(command)` | Run a shell command |
| `self.helpers.run_live(command)` | Run a shell command, stream output |
| `self.helpers.as_completed(tasks)` | Async iteration of completed tasks |
| `self.helpers.wordlist(url_or_path)` | Download/cache a wordlist |
| `self.helpers.rand_string(n)` | Random string of length n |
| `self.helpers.regexes.email_regex` | Pre-compiled email regex |
| `self.helpers.add_get_params(url, params)` | Add query params to a URL |
| `self.helpers.quote(s)` | URL-encode a string |
| `self.helpers.make_ip_type(s)` | Convert string to `ipaddress` object |
| `self.helpers.parse_port_string(s)` | Parse port range string (e.g. `"80,443,8000-9000"`) |
| `self.helpers.top_tcp_ports(n)` | Get top N TCP ports |

There are hundreds more in `bbot/core/helpers/misc.py`. Browse them before writing utility code yourself.

---

## Writing a Module

### Quick Start

1. Create `bbot/modules/my_module.py`
2. Define a class that inherits from `BaseModule`
3. Set `watched_events`, `produced_events`, `flags`, and `meta`
4. Implement `handle_event()`
5. Create `bbot/test/test_step_2/module_tests/test_module_my_module.py`

Here's a minimal module:

```python
from bbot.modules.base import BaseModule


class my_module(BaseModule):
    watched_events = ["DNS_NAME"]
    produced_events = ["EMAIL_ADDRESS"]
    flags = ["passive", "email-enum"]
    meta = {
        "description": "Query example.com for email addresses",
        "created_date": "2025-01-01",
        "author": "@you",
    }

    async def handle_event(self, event):
        url = f"https://api.example.com/lookup?domain={event.data}"
        r = await self.helpers.request(url)
        if r and r.status_code == 200:
            for email in r.json().get("emails", []):
                await self.emit_event(
                    email,
                    "EMAIL_ADDRESS",
                    parent=event,
                    context=f"{{module}} queried example.com and found {{event.type}}: {{event.data}}",
                )
```

And its test:

```python
from .base import ModuleTestBase


class TestMyModule(ModuleTestBase):
    async def setup_after_prep(self, module_test):
        module_test.blasthttp_mock.add_response(
            url="https://api.example.com/lookup?domain=blacklanternsecurity.com",
            json={"emails": ["[email protected]"]},
        )

    def check(self, module_test, events):
        assert any(e.data == "[email protected]" and e.type == "EMAIL_ADDRESS" for e in events), (
            "Failed to find email"
        )
```

### Module Lifecycle

```
setup()  -->  handle_event() (called many times)  -->  finish()  -->  report()  -->  cleanup()
```

1. **`setup()`** - one-time initialization (validate config, download data, check API keys)
2. **`handle_event(event)`** - called for each matching event
3. **`finish()`** - called when the scan is finishing; can still emit events
4. **`report()`** - called once after finish; for summary output
5. **`cleanup()`** - called last; close files, delete temp data; **cannot** emit events

---

### Module Attributes Reference

#### Event Configuration

##### `watched_events` (list)
Event types this module wants to process. The module's `handle_event()` is only called for these types.

```python
# sslcert.py - watches for open ports to grab SSL certs from
watched_events = ["OPEN_TCP_PORT"]

# newsletters.py - watches HTTP responses to scan HTML
watched_events = ["HTTP_RESPONSE"]

# json.py (output module) - watches everything
watched_events = ["*"]
```

##### `produced_events` (list)
Event types this module may emit. Used for dependency resolution and documentation.

```python
# sslcert.py - can discover hostnames and emails from certificates
produced_events = ["DNS_NAME", "EMAIL_ADDRESS"]

# portscan.py - finds open ports
produced_events = ["OPEN_TCP_PORT"]
```

##### `flags` (list)
Tags that describe the module's behavior. Must include at least one activity flag (`passive` or `active`). Must also include `safe`, `loud`, or `invasive` (or a combination of `loud` and `invasive`).

Common flags:
- `passive` / `active` - whether the module touches the target directly
- `safe` - non-intrusive and non-destructive
- `loud` - generates a large amount of network traffic
- `invasive` - intrusive or potentially destructive
- `subdomain-enum` - participates in subdomain enumeration
- `web` - basic web scanning
- `email-enum` - email discovery

```python
# crt.py - queries a third-party API, never touches the target
flags = ["subdomain-enum", "passive"]

# sslcert.py - connects directly to target ports
flags = ["affiliates", "subdomain-enum", "email-enum", "active", "web"]
```

##### `meta` (dict)
Module metadata. Must include `description`, `created_date`, and `author`. Set `auth_required: True` if the module needs an API key.

```python
meta = {
    "description": "Query crt.sh (certificate transparency) for subdomains",
    "created_date": "2022-05-13",
    "author": "@TheTechromancer",
}

# For API-key modules:
meta = {"description": "Query API for subdomains", "auth_required": True}
```

---

#### Options

##### `options` / `options_desc` (dict)
User-configurable settings. Access them via `self.config.get("option_name")`.

```python
# robots.py - configurable parsing options
options = {"include_sitemap": False, "include_allow": True, "include_disallow": True}
options_desc = {
    "include_sitemap": "Include 'sitemap' entries",
    "include_allow": "Include 'Allow' Entries",
    "include_disallow": "Include 'Disallow' Entries",
}

# In handle_event():
if self.config.get("include_sitemap") is True:
    ...
```

```python
# sslcert.py - timeout and behavior options
options = {"timeout": 5.0, "skip_non_ssl": True}
options_desc = {"timeout": "Socket connect timeout in seconds", "skip_non_ssl": "Don't try common non-SSL ports"}
```

---

#### Scope & Filtering

##### `scope_distance_modifier` (int or None) -- default: `0`
Controls which events the module accepts based on how far they are from the target.

- `0` (default) - accept events up to the scan's configured search distance
- `1` - accept events up to search distance + 1
- `None` - accept all events regardless of distance

```python
# sslcert.py - looks one hop beyond normal scope, because certificate names
# found on in-scope hosts often reveal related infrastructure
scope_distance_modifier = 1
```

##### `in_scope_only` (bool) -- default: `False`
Only accept events that are explicitly in-scope (distance == 0). More restrictive than `scope_distance_modifier = 0`.

```python
# robots.py - only fetch robots.txt for in-scope hosts
in_scope_only = True
```

##### `target_only` (bool) -- default: `False`
Only accept the initial target/seed events. Useful for modules that should only run once against the original targets.

##### `accept_seeds` (bool) -- default: `True` for passive, `False` for active
Whether to process seed events (the initial targets provided to the scan).

##### `accept_url_special` (bool) -- default: `False`
Whether to accept "special" URLs (e.g. JavaScript files) that are not normally distributed to web modules.

```python
# http.py - needs to process all URLs including special ones
accept_url_special = True
```

---

#### Deduplication

##### `accept_dupes` (bool) -- default: `False`
Whether to accept the same event more than once. Most modules should leave this `False`.

```python
# Output modules set this True because they need to see every event
accept_dupes = True
```

##### `suppress_dupes` (bool) -- default: `True`
Whether to suppress duplicate *outgoing* events. Prevents the same event from being emitted twice.

##### `per_host_only` (bool) -- default: `False`
Only process one event per unique host. After processing `1.2.3.4`, skip any future events for `1.2.3.4`.

##### `per_hostport_only` (bool) -- default: `False`
Only process one event per unique host:port combination.

```python
# robots.py - only fetch robots.txt once per host:port
per_hostport_only = True
```

##### `per_domain_only` (bool) -- default: `False`
Only process one event per unique root domain. After processing `www.example.com`, skip `api.example.com`.

```python
# emailformat.py - one API query per domain is enough
per_domain_only = True
```

##### `_incoming_dedup_hash(self, event)` -- override for custom dedup
Override this to define custom deduplication logic. Return a hash (int) or `(hash, reason_string)`.

```python
# securitytxt.py - dedupe by parent domain so we only check security.txt once
# per parent domain, not once per subdomain
def _incoming_dedup_hash(self, event):
    parent_domain = self.helpers.parent_domain(event.data)
    return hash(parent_domain), "already processed parent domain"
```

```python
# subdomain_enum.py template - dedupe by highest or lowest parent domain
def _incoming_dedup_hash(self, event):
    return hash(self.make_query(event)), f"dedup_strategy={self.dedup_strategy}"
```

---

#### Concurrency & Batching

##### `_module_threads` (int) -- default: `1`
How many `handle_event()` calls can run concurrently. Increase this for I/O-bound modules.

```python
# sslcert.py - connects to many hosts in parallel
_module_threads = 25
```

##### `_batch_size` (int) -- default: `1`
When > 1, events are collected into batches and passed to `handle_batch(*events)` instead of `handle_event()`. Useful for tools that work better with bulk input.

```python
# portscan.py - masscan is most efficient with all targets at once
batch_size = 1000000


async def handle_batch(self, *events):
    targets, correlator = await self.make_targets(events, self.syn_scanned)
    async for ip, port, parent_event in self.masscan(targets, correlator):
        await self.emit_open_port(ip, port, parent_event)
```

##### `_shuffle_incoming_queue` (bool) -- default: `True`
Whether to randomize the order of incoming events. Set to `False` when order matters.

```python
# portscan.py - processes all events together, order doesn't matter but
# we disable shuffle because batch_size is huge
_shuffle_incoming_queue = False
```

---

#### Dependencies

##### `deps_pip` (list)
Python packages to install.

```python
# sslcert.py
deps_pip = ["pyOpenSSL~=25.3.0"]
```

##### `deps_apt` (list)
System packages to install.

```python
# sslcert.py
deps_apt = ["openssl"]
```

##### `deps_modules` (list)
Other BBOT modules that must be enabled for this module to work.

##### `deps_shell` (list)
Shell commands to run for installation (uses Ansible's `shell` module).

##### `deps_ansible` (list)
Ansible tasks for complex dependency installation (downloading binaries, etc.).

```python
# fingerprintx.py - downloads a Go binary
deps_ansible = [
    {
        "name": "Download fingerprintx",
        "unarchive": {
            "src": "https://github.com/.../fingerprintx_{version}_{platform}_{arch}.tar.gz",
            "include": "fingerprintx",
            "dest": "#{BBOT_TOOLS}",
            "remote_src": True,
        },
    },
]
```

---

#### Priority & Queue

##### `_priority` (int) -- default: `3`
Module priority from 1 (highest) to 5 (lowest). Lower-priority modules get events first.

```python
# sslcert.py - runs early because other modules depend on the hostnames it discovers
_priority = 2
```

##### `_qsize` (int) -- default: `1000`
Outgoing event queue size. A smaller queue creates backpressure that helps with rate limiting.

```python
# subdomain_enum.py template - small queue to combat API rate limiting
_qsize = 10
```

##### `_preserve_graph` (bool) -- default: `False`
Accept duplicate events that are needed for complete event chain construction. Only used by output modules.

```python
# json.py - needs complete event chains for accurate output
_preserve_graph = True
```

##### `_stats_exclude` (bool) -- default: `False`
Exclude this module from scan statistics. Used by output and report modules.

##### `_disable_auto_module_deps` (bool) -- default: `False`
Prevent BBOT from automatically enabling dependency modules. For example, if your module watches `URL` events, BBOT normally auto-enables `http`. Set this to `True` to prevent that.

---

### Key Methods

#### `handle_event(self, event)` -- the core method

Called once for each matching event. This is where your module does its work.

```python
# robots.py - fetch and parse robots.txt
async def handle_event(self, event):
    host = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}/"
    url = f"{host}robots.txt"
    result = await self.helpers.request(url)
    if result:
        body = result.text
        if body:
            for line in body.split("\n"):
                if line.startswith("Disallow:"):
                    path = line.split(": ", 1)[1].lstrip("/")
                    await self.emit_event(
                        f"{host}{path}",
                        "URL_UNVERIFIED",
                        parent=event,
                        tags=["spider-danger"],
                    )
```

#### `handle_batch(self, *events)` -- bulk processing

Used when `_batch_size > 1`. Receives multiple events at once.

```python
# portscan.py - bulk port scanning with masscan
async def handle_batch(self, *events):
    targets, correlator = await self.make_targets(events, self.syn_scanned)
    async for ip, port, parent_event in self.masscan(targets, correlator):
        await self.emit_open_port(ip, port, parent_event)
```

#### `filter_event(self, event)` -- custom event filtering

Called before `handle_event()`. Return `True` to accept, `False` to reject, or `(False, "reason")` to reject with a logged reason.

```python
# sslcert.py - skip ports that don't typically use SSL
async def filter_event(self, event):
    if self.skip_non_ssl and event.port in self.non_ssl_ports:
        return False, f"Port {event.port} doesn't typically use SSL"
    return True
```

```python
# subdomain_enum.py template - reject wildcards and cloud resources
async def filter_event(self, event):
    query = self.make_query(event)
    is_wildcard = await self._is_wildcard(query)
    if self.reject_wildcards and is_wildcard:
        return False, "Event is a wildcard domain"
    return True, ""
```

#### `setup(self)` -- one-time initialization

Return values:
- `True` -- success
- `(True, "message")` -- success with message
- `None` or `(None, "message")` -- **soft fail**: module is disabled, scan continues
- `False` or `(False, "message")` -- **hard fail**: scan aborts

```python
# portscan.py - validates config, checks masscan, checks IPv6 support
async def setup(self):
    self.top_ports = self.config.get("top_ports", 100)
    self.rate = self.config.get("rate", 300)
    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 '{self.ports}': {e}"
    # ...
    return True
```

```python
# subdomain_enum_apikey template - soft-fail if API key is missing
async def setup(self):
    await super().setup()
    return await self.require_api_key()
    # Returns (None, "No API key set") if missing, disabling the module
```

#### `finish(self)` -- called when scan is finishing

Can still emit events. May be called multiple times if new activity is detected.

#### `report(self)` -- summary output

Called once after `finish()`. Use for generating tables or summary data.

```python
# asn.py - output ASN statistics
async def report(self):
    self.log_table(table_data, headers=["ASN", "Subnet", "Count"], table_name="asns")
```

#### `cleanup(self)` -- resource cleanup

Called once at the very end. Close files, delete temp files. **Cannot emit events.**

```python
# json.py
async def cleanup(self):
    if getattr(self, "_file", None) is not None:
        with suppress(Exception):
            self.file.close()
```

```python
# portscan.py
async def cleanup(self):
    with suppress(Exception):
        self.exclude_file.unlink()
```

---

### Emitting Events

#### `emit_event(data, event_type, parent, **kwargs)`

Creates and queues an event for processing by other modules.

```python
# Simple string event
await self.emit_event("sub.example.com", "DNS_NAME", parent=event)

# With context (used for discovery chain documentation)
await self.emit_event(
    "sub.example.com",
    "DNS_NAME",
    parent=event,
    context=f"{{module}} queried crt.sh and found {{event.type}}: {{event.data}}",
)

# With tags
await self.emit_event(url, "URL_UNVERIFIED", parent=event, tags=["spider-danger"])

# FINDING event (dict data)
await self.emit_event(
    {
        "host": str(event.host),
        "description": "Found something interesting",
        "url": event.data["url"],
        "severity": "HIGH",
    },
    "FINDING",
    parent=event,
)
```

#### `make_event(data, event_type, parent, **kwargs)`

Creates an event without emitting it. Useful when you need to inspect or modify it first.

```python
ssl_event = self.make_event(hostname, "DNS_NAME", parent=event, raise_error=True)
if ssl_event:
    await self.emit_event(ssl_event, tags=["affiliate"])
```

---

### API Helpers

#### `api_request(url, **kwargs)`

HTTP request with automatic retry, rate-limit handling (429), API key cycling, and failure tracking. After too many failures, the module enters error state.

```python
r = await self.api_request("https://api.example.com/search?q=test")
if r and r.status_code == 200:
    data = r.json()
```

#### `require_api_key()`

Validates that an API key is configured. Call in `setup()`.

```python
async def setup(self):
    return await self.require_api_key()
```

#### `api_page_iter(url, page_size=100, **kwargs)`

Async generator for paginated API results. URL can contain `{page}`, `{page_size}`, and `{offset}` placeholders.

```python
async for page in self.api_page_iter("https://api.example.com/search?q=test&page={page}&limit={page_size}"):
    if not page.get("results"):
        break
    for result in page["results"]:
        await self.emit_event(result["hostname"], "DNS_NAME", parent=event)
```

---

### Running External Processes

```python
# Run a command and get the result
result = await self.run_process(["nmap", "-p", "22,80", target])
if result.returncode == 0:
    output = result.stdout

# Stream output line-by-line (for long-running tools)
async for line in self.run_process_live(["masscan", "-oJ", "-", ...]):
    data = json.loads(line)
```

---

### Logging

```python
self.debug("Low-level detail")  # only visible with -d flag
self.verbose("Useful but not critical")  # visible with -v flag
self.info("Standard info")
self.success("Something good happened")  # green
self.warning("Something concerning")  # orange
self.error("Something failed")  # red

# "Huge" variants: entire line in bold color
self.hugesuccess("Major discovery!")
self.hugewarning("Major concern!")
```

---

### Templates

For common patterns, inherit from a template instead of `BaseModule` directly. Templates live in `bbot/modules/templates/`:

- **`subdomain_enum`** - passive subdomain enumeration via free API. Handles dedup, wildcard rejection, query building.
- **`subdomain_enum_apikey`** - same as above but requires an API key.
- **`shodan`** - Shodan API integration.
- **`github`** - GitHub API integration.
- **`censys`** - Censys API integration.
- **`bucket`** - Cloud storage bucket enumeration.
- **`webhook`** - Webhook output.

Example: `crt.py` inherits from `subdomain_enum` and only needs to override the request/parse logic:

```python
from bbot.modules.templates.subdomain_enum import subdomain_enum


class crt(subdomain_enum):
    flags = ["subdomain-enum", "passive"]
    watched_events = ["DNS_NAME"]
    produced_events = ["DNS_NAME"]
    meta = {
        "description": "Query crt.sh (certificate transparency) for subdomains",
        "created_date": "2022-05-13",
        "author": "@TheTechromancer",
    }
    base_url = "https://crt.sh"

    async def request_url(self, query):
        params = {"q": f"%.{query}", "output": "json"}
        url = self.helpers.add_get_params(self.base_url, params).geturl()
        return await self.api_request(url, timeout=self.http_timeout + 30)

    async def parse_results(self, r, query):
        results = set()
        for cert_info in r.json():
            domain = cert_info.get("name_value")
            if domain:
                for d in domain.splitlines():
                    results.add(d.lower())
        return results
```

---

### Module Types

#### Scan Modules (default)
Normal modules that watch events and produce new ones. This is what you'll write 95% of the time.

#### Output Modules
Inherit from `BaseOutputModule`. Receive all events and write them somewhere.

```python
from bbot.modules.output.base import BaseOutputModule


class my_output(BaseOutputModule):
    watched_events = ["*"]
    meta = {"description": "Custom output"}
    _preserve_graph = True  # maintain complete event chains

    async def handle_event(self, event):
        # write event to file, database, API, etc.
        ...
```

Output modules automatically get:
- `accept_dupes = True`
- `scope_distance_modifier = None` (see all events)
- `_stats_exclude = True`

#### Internal Modules
Inherit from `BaseInternalModule`. System-level modules that aren't exposed to users.

#### Intercept Modules
Inherit from `BaseInterceptModule`. Special high-priority modules that can modify or reject events before they reach normal modules. Used for DNS resolution, cloud detection, etc. You probably don't need to write one.

---

### Writing Tests

Every module needs a test in `bbot/test/test_step_2/module_tests/`. The test file must be named `test_module_<name>.py`.

Test classes inherit from `ModuleTestBase` and follow this pattern:

```python
from .base import ModuleTestBase


class TestMyModule(ModuleTestBase):
    # Optional: override targets (default: ["blacklanternsecurity.com"])
    targets = ["http://127.0.0.1:8888"]

    # Optional: override which modules are enabled
    modules_overrides = ["http", "my_module"]

    # Optional: override config
    config_overrides = {"modules": {"my_module": {"some_option": True}}}

    async def setup_before_prep(self, module_test):
        """Called BEFORE the scan is prepared. Set up HTTP mocks here."""
        pass

    async def setup_after_prep(self, module_test):
        """Called AFTER the scan is prepared. Modify modules, add mocks here."""
        # Mock an HTTP response
        module_test.blasthttp_mock.add_response(
            url="https://api.example.com/lookup?domain=blacklanternsecurity.com",
            json={"results": ["sub.blacklanternsecurity.com"]},
        )

        # Mock DNS
        await module_test.mock_dns(
            {
                "blacklanternsecurity.com": {"A": ["127.0.0.88"]},
            }
        )

        # Mock an HTTP server response
        module_test.set_expect_requests(
            expect_args={"method": "GET", "uri": "/robots.txt"},
            respond_args={"response_data": "Disallow: /secret/"},
        )

    def check(self, module_test, events):
        """Verify the scan produced the expected events."""
        assert any(e.data == "sub.blacklanternsecurity.com" and e.type == "DNS_NAME" for e in events), (
            "Failed to find subdomain"
        )
```

The test lifecycle runs:
1. `setup_before_prep()` - set up mocks
2. Scan `_prep()` - loads modules, config
3. `setup_after_prep()` - modify scan state
4. Scan runs and collects events
5. `check()` - your assertions

### Test Utilities

- **`module_test.blasthttp_mock`** - mock HTTP responses 
- **`module_test.httpserver`** - real HTTP server on port 8888
- **`module_test.httpserver_ssl`** - real HTTPS server on port 9999
- **`module_test.mock_dns(data)`** - mock DNS responses
- **`module_test.mock_interactsh(name)`** - mock out-of-band interactions
- **`module_test.module`** - reference to the module instance being tested
- **`module_test.scan`** - reference to the Scanner instance

Real example -- `test_module_robots.py`:

```python
class TestRobots(ModuleTestBase):
    targets = ["http://127.0.0.1:8888"]
    modules_overrides = ["http", "robots"]
    config_overrides = {"modules": {"robots": {"include_sitemap": True}}}

    async def setup_after_prep(self, module_test):
        robots = "Allow: /allow/\nDisallow: /disallow/\nSitemap: http://127.0.0.1:8888/sitemap.txt"
        module_test.set_expect_requests(
            expect_args={"method": "GET", "uri": "/robots.txt"},
            respond_args={"response_data": robots},
        )

    def check(self, module_test, events):
        assert any(e.data == "http://127.0.0.1:8888/allow/" for e in events)
        assert any(e.data == "http://127.0.0.1:8888/disallow/" for e in events)
        assert any(e.data == "http://127.0.0.1:8888/sitemap.txt" for e in events)
```