diff --git a/.github/workflows/format-lint.yml b/.github/workflows/format-lint.yml index 6ce1e1d..5fab5db 100644 --- a/.github/workflows/format-lint.yml +++ b/.github/workflows/format-lint.yml @@ -1,33 +1,34 @@ -name: Format & Lint +name: Format, Lint & Test on: push: - branches: [ "master" ] + branches: ["master"] pull_request: - branches: [ "master" ] + branches: ["master"] permissions: contents: read jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install black ruff - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Format check - run: | - make format-check - - name: Lint check - run: | - make lint-check + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Format check + run: ruff format --check . + - name: Lint check + run: ruff check . + - name: Test + run: pytest diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index bcd0f2f..0000000 --- a/.pylintrc +++ /dev/null @@ -1,2 +0,0 @@ -[MASTER] -disable=too-many-branches,too-many-statements \ No newline at end of file diff --git a/Makefile b/Makefile index af3bd30..ccb6d42 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,37 @@ +.PHONY: env format format-check lint lint-fix test build publish local-install update-from-upstream clean + +PY ?= .env/bin/python + env: python3 -m venv .env - .env/bin/pip3 install --upgrade pip - .env/bin/pip3 install --upgrade setuptools - .env/bin/pip3 install --upgrade requests black ruff wheel twine + $(PY) -m pip install --upgrade pip + $(PY) -m pip install --upgrade -e ".[dev]" + +format: + $(PY) -m ruff format . format-check: - if [ -f .env/bin/black ]; then .env/bin/black --check .; else black --check .; fi + $(PY) -m ruff format --check . + +lint: + $(PY) -m ruff check . + +lint-fix: + $(PY) -m ruff check --fix . -lint-check: - if [ -f .env/bin/ruff ]; then .env/bin/ruff .; else ruff .; fi +test: + $(PY) -m pytest -wheel: - -rm dist/* - .env/bin/python setup.py bdist_wheel --universal +build: + -rm -rf dist + $(PY) -m build publish: - .env/bin/twine upload --skip-existing dist/* + $(PY) -m twine upload --skip-existing dist/* local-install: - -.env/bin/pip3 uninstall honeydb - .env/bin/pip3 install dist/* + -$(PY) -m pip uninstall -y honeydb + $(PY) -m pip install dist/*.whl update-from-upstream: # update master branch from honeydbio @@ -31,5 +43,5 @@ update-from-upstream: clean: find . -name "*.pyc" -type f -delete - rm -rf dist - rm -rf build + rm -rf dist build .pytest_cache .ruff_cache + find . -name "__pycache__" -type d -exec rm -rf {} + diff --git a/README.md b/README.md index 9211f1c..f832be1 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,220 @@ # honeydb-python -[![Format & Lint](https://github.com/honeydbio/honeydb-python/actions/workflows/format-lint.yml/badge.svg)](https://github.com/honeydbio/honeydb-python/actions/workflows/format-lint.yml) +[![Format, Lint & Test](https://github.com/honeydbio/honeydb-python/actions/workflows/format-lint.yml/badge.svg)](https://github.com/honeydbio/honeydb-python/actions/workflows/format-lint.yml) +[![PyPI version](https://img.shields.io/pypi/v/honeydb.svg)](https://pypi.org/project/honeydb/) +[![Python versions](https://img.shields.io/pypi/pyversions/honeydb.svg)](https://pypi.org/project/honeydb/) -HoneyDB Python Module +A Python API wrapper and command-line tool for the [HoneyDB](https://honeydb.io) API. -### Install +HoneyDB provides real-time threat intelligence collected from a distributed network of +honeypots — bad hosts, IP reputation, ASN activity, CVE sightings, network info, cloud/datacenter +IP ranges, and more. -`pip install honeydb` +- **Full API coverage** — every current HoneyDB endpoint is exposed. +- **Modern & typed** — Python 3.10+, full type hints, ships a `py.typed` marker. +- **Robust HTTP** — pooled `requests.Session` with automatic retries and typed exceptions. +- **Ergonomic CLI** — a git-style subcommand interface: `honeydb ip 8.8.8.8`. -### CLI Usage +## Requirements +- Python 3.10+ +- A HoneyDB API ID and API key ([sign in](https://honeydb.io) to get yours). + +## Installation + +```bash +pip install honeydb +``` + +## Authentication + +All requests require an API ID and API key. Provide them via environment variables: + +```bash +export HONEYDB_API_ID= +export HONEYDB_API_KEY= ``` -$ export HONEYDB_API_ID= -$ export HONEYDB_API_KEY= -$ honeydb --bad-hosts + +The CLI also accepts `--api-id` / `--api-key`, and the library takes them as constructor +arguments. + +## CLI usage + +```bash +# Bad hosts seen in the last 24 hours +honeydb bad-hosts + +# Full context for an IP (pretty-printed) +honeydb ip 8.8.8.8 --pretty + +# Just the geolocation view of that IP +honeydb ip 8.8.8.8 --geo + +# ASN organization + its prefixes +honeydb asn 15169 +honeydb asn 15169 --prefixes + +# Check an IP against a specific list +honeydb ipinfo 185.220.101.1 --source tor + +# Cloud/datacenter IP ranges (does not count against monthly limits) +honeydb datacenter aws + +# Your own sensor data for a date +honeydb sensor-data --date 2025-04-01 +honeydb sensor-data --date 2025-04-01 --count + +# Manage monitors +honeydb monitors list +honeydb monitors create --json '[{"monitor_type":"asn","monitor_value":"401120","description":"ASN Example"}]' +honeydb monitors delete --id 122 123 ``` -Display help message for more CLI options: +Run `honeydb --help` or `honeydb --help` for the full command tree. Global flags +`--pretty/-p` and `--timeout` apply to any command. Output is JSON on stdout; errors go to +stderr with a non-zero exit code. + +### Commands -`honeydb --help` +| Command | Description | +| --- | --- | +| `bad-hosts [--service S] [--mydata]` | Bad hosts (last 24h), optionally by service. | +| `ip [--geo\|--netinfo\|--threatinfo\|--scanner\|--history\|--cve]` | IP context, or a single view. | +| `ip-cidr ` | All IP addresses within a network range. | +| `asn [--prefixes]` | ASN organization info or its prefixes. | +| `asns [--days 1\|7]` | ASNs seen in the last 1 (default) or 7 days. | +| `cve ` | IP history for a CVE. | +| `cve-ip ` | CVE history for an IP. | +| `sensor-data --date D [--from-id ID] [--count] [--all]` | Your sensor event data for a date. | +| `services` | Emulated services (last 24h). | +| `stats --year Y --month M` | Summary stats for a year/month. | +| `monitors {list,logs,notifications,create,delete}` | Manage monitors. | +| `nodes [--mydata]` | honeydb-agent nodes (last 3 days). | +| `payload-history {remote-hosts,attributes,...}` | Payload history data. | +| `internet-scanner [--info]` | Whether an IP is a known internet scanner. | +| `ipinfo [--source SRC]` | Check an IP against known IP lists. | +| `netinfo {lookup,network-addresses,prefixes,as-name,geolocation} ` | Network info (no monthly limit). | +| `datacenter ` | Cloud/datacenter IP ranges (no monthly limit). | -### Module Usage +`ipinfo --source` values: `bogon`, `tor`, `sansip`, `ciarmy`, `et-compromised`, +`project-honeypot`, `pallebone`, `threatfox`, `blocklist_net_ua`. +`datacenter` providers: `aws`, `azure`, `azure/china`, `azure/germany`, `azure/gov`, +`cloudflare`, `gcp`, `ibm`, `oracle`. + +## Library usage + +```python +from honeydb import Client + +with Client("api_id", "api_key") as honeydb: + hosts = honeydb.bad_hosts() + context = honeydb.ip("8.8.8.8") + is_tor = honeydb.ipinfo_source("tor", "185.220.101.1") + ranges = honeydb.datacenter("aws") ``` -from honeydb import api -honeydb = api.Client('api_id', 'api_key') -print(honeydb.bad_hosts()) + +The client can also be used without the context manager (call `.close()` when done), and +you can pass a shared `requests.Session`, a custom `timeout`, or a different `base_url`: + +```python +client = Client("api_id", "api_key", timeout=10, retries=5) +try: + print(client.services()) +finally: + client.close() ``` + +### Error handling + +Every failed request raises a typed exception, all subclasses of `HoneyDBError`: + +```python +from honeydb import ( + Client, + HoneyDBError, + HoneyDBAuthError, + HoneyDBNotFoundError, + HoneyDBRateLimitError, +) + +with Client("api_id", "api_key") as honeydb: + try: + honeydb.ip("8.8.8.8") + except HoneyDBAuthError: + print("Check your API credentials.") + except HoneyDBRateLimitError as error: + print(f"Rate limited; retry after {error.retry_after}s") + except HoneyDBError as error: + print(f"Request failed with HTTP {error.status_code}: {error}") +``` + +### Monitors + +```python +with Client("api_id", "api_key") as honeydb: + honeydb.create_monitors([ + {"monitor_type": "ip_address", "ip_address": "196.251.81.54", + "description": "IP Address Example"}, + {"monitor_type": "asn", "monitor_value": "401120", + "description": "ASN Example"}, + ]) + monitors = honeydb.monitors() + honeydb.delete_monitors([m["id"] for m in monitors]) +``` + +## API reference + +The `Client` exposes one method per endpoint, grouped below. + +- **Bad hosts:** `bad_hosts(mydata=False)`, `bad_hosts_by_service(service, mydata=False)` +- **IP context:** `ip(ip)`, `ip_geo(ip)`, `ip_netinfo(ip)`, `ip_threatinfo(ip)`, + `ip_internet_scanner(ip)`, `ip_history(ip)`, `ip_cve(ip)`, `ip_cidr(cidr)` +- **ASN:** `asn(n)`, `asn_prefixes(n)`, `asns()`, `asns_7d()` +- **CVE:** `cve(cve)`, `cve_ip(ip)` +- **Sensor data:** `sensor_data(date, from_id=None, mydata=True)`, `sensor_data_count(date, mydata=True)` +- **Services / stats:** `services()`, `stats(year, month)` +- **Monitors:** `monitors()`, `create_monitors(list)`, `delete_monitors(ids)`, + `monitors_logs()`, `monitors_notifications()` +- **Nodes:** `nodes(mydata=False)` +- **Payload history:** `payload_history_remote_hosts()`, `payload_history_attributes()`, + `payload_history_attribute(attr)` (plus API-deprecated helpers) +- **Internet scanner:** `internet_scanner(ip)`, `internet_scanner_info(ip)` +- **IP info lists:** `ipinfo(ip)`, `ipinfo_source(source, ip)` +- **Net info (no monthly limit):** `netinfo_lookup(ip)`, `netinfo_network_addresses(cidr)`, + `netinfo_prefixes(asn)`, `netinfo_as_name(asn)`, `netinfo_geolocation(ip)` +- **Datacenter (no monthly limit):** `datacenter(provider)` + +See the [HoneyDB API documentation](https://honeydb.io/threats) for endpoint details and +response formats. + +## Migrating from v1.x + +v2.0.0 is a ground-up rewrite. Notable changes: + +- **New import surface.** `from honeydb import Client` (the `from honeydb import api; + api.Client(...)` form still works). +- **The CLI is now subcommand-based.** For example, `honeydb --bad-hosts` becomes + `honeydb bad-hosts`, and `honeydb --netinfo-lookup 1.2.3.4` becomes + `honeydb netinfo lookup 1.2.3.4`. +- **Replaced endpoints were dropped** in favor of their modern equivalents: + the old `ip_history()` / `ip-context` top-level methods are replaced by `ip()` and + `ip_history()` under the `/ip/` family, and `stats_asn()` is replaced by `asns()`. +- **Errors now raise typed exceptions** (`HoneyDBError` and subclasses) instead of returning + raw response bodies. + +## Development + +```bash +make env # create .env venv and install with dev extras +make format # ruff format +make lint # ruff check +make test # pytest (uses mocked HTTP, no API keys needed) +make build # build sdist + wheel +``` + +This project uses [ruff](https://docs.astral.sh/ruff/) for both formatting and linting. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/README.rst b/README.rst deleted file mode 100644 index 744e073..0000000 --- a/README.rst +++ /dev/null @@ -1,42 +0,0 @@ -HoneyDB -================== - -.. image:: https://img.shields.io/pypi/v/honeydb.svg - :target: https://pypi.python.org/pypi/honeydb/ - :alt: Latest Version - -To learn more about HoneyDB visit `About HoneyDB`_. - -To lean more about the HoneyDB API visit `HoneyDB REST API`_. - -The ``honeydb`` command is a CLI tool for interacting with the HoneyDB API. - -Installation ------------- -.. code-block:: bash - - $ pip install honeydb - - -CLI usage ---------- -.. code-block:: bash - - $ export HONEYDB_API_ID= - $ export HONEYDB_API_KEY= - $ honeydb --bad-hosts - - -Module usage ------------- -.. code-block:: python - - from honeydb import api - honeydb = api.Client('api_id', 'api_key') - print(honeydb.bad_hosts()) - -More details and the latest updates can be found on the `GitHub Project Page`_. - -.. _About HoneyDB: https://honeydb.io/#about -.. _HoneyDB REST API: https://honeydb.io/#threats -.. _GitHub Project Page: https://github.com/honeydbio/honeydb-python diff --git a/example.py b/example.py index 9da862c..2e85d36 100644 --- a/example.py +++ b/example.py @@ -1,60 +1,59 @@ -#!/usr/bin/env python -""" -Example script for using the HoneyDB API client +#!/usr/bin/env python3 +"""Example usage of the HoneyDB API client. + +Credentials are read from environment variables: -In this example, API credentials must be exported to environment variables: -export HONEYDB_API_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -export HONEYDB_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + export HONEYDB_API_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + export HONEYDB_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx """ -import os -import json import datetime -from honeydb import api +import json +import os +from honeydb import Client, HoneyDBError -def out(json_data): - """ - Output json data in pretty format - """ - print(json.dumps(json_data, indent=4)) +def out(data: object) -> None: + """Pretty-print JSON data.""" + print(json.dumps(data, indent=2, sort_keys=True)) -def main(): - """ - The main fuction for executing example code - """ - # Get API keys from environment variables and create the - # HoneyDB Client API object. +def main() -> None: api_id = os.environ["HONEYDB_API_ID"] api_key = os.environ["HONEYDB_API_KEY"] - honeydb = api.Client(api_id, api_key) - - try: - # Get bad hosts - bad_hosts = honeydb.bad_hosts() - out(bad_hosts) - - # Get sensor data count - today = datetime.datetime.today().strftime("%Y-%m-%d") - data_count = honeydb.sensor_data_count(sensor_data_date=today) - out(data_count) - - # Get sensor data - data = honeydb.sensor_data(sensor_data_date=today) - out(data) - - """ - # Example with from_id. - # See more information on using from_id here: - # https://honeydb.io/threats#sensor_data_filtered - data = honeydb.sensor_data(sensor_data_date=today, from_id=84869618) - out(data) - """ - - except Exception as error: - print(str(error)) + + # The client is a context manager so its connection pool is cleaned up. + with Client(api_id, api_key) as honeydb: + try: + # Bad hosts seen across the honeypot network in the last 24 hours. + out(honeydb.bad_hosts()) + + # Full context for an IP address (netinfo, threat, history, ...). + out(honeydb.ip("8.8.8.8")) + + # Check an IP against known IP lists. + out(honeydb.ipinfo("8.8.8.8")) + out(honeydb.ipinfo_source("tor", "8.8.8.8")) + + # Network info lookups do not count against your monthly limit. + out(honeydb.netinfo_as_name(15169)) + + # Your own sensor data for today. + today = datetime.date.today().isoformat() + out(honeydb.sensor_data_count(today)) + out(honeydb.sensor_data(today)) + + # Manage monitors. + out(honeydb.monitors()) + # honeydb.create_monitors([ + # {"monitor_type": "asn", "monitor_value": "401120", + # "description": "ASN Example"}, + # ]) + # honeydb.delete_monitors([122, 123]) + + except HoneyDBError as error: + print(f"HoneyDB API error: {error}") if __name__ == "__main__": diff --git a/honeydb/__init__.py b/honeydb/__init__.py index e69de29..ea0f8e3 100644 --- a/honeydb/__init__.py +++ b/honeydb/__init__.py @@ -0,0 +1,25 @@ +"""HoneyDB — Python API wrapper and CLI for the HoneyDB API. + +See https://honeydb.io for more information. +""" + +from honeydb.api.client import DATACENTER_PROVIDERS, IPINFO_SOURCES, Client +from honeydb.exceptions import ( + HoneyDBAuthError, + HoneyDBError, + HoneyDBNotFoundError, + HoneyDBRateLimitError, +) + +__version__ = "2.0.0" + +__all__ = [ + "Client", + "DATACENTER_PROVIDERS", + "IPINFO_SOURCES", + "HoneyDBError", + "HoneyDBAuthError", + "HoneyDBNotFoundError", + "HoneyDBRateLimitError", + "__version__", +] diff --git a/honeydb/api/__init__.py b/honeydb/api/__init__.py index 98589be..671b023 100644 --- a/honeydb/api/__init__.py +++ b/honeydb/api/__init__.py @@ -1,5 +1,5 @@ -""" -HoneyDB API Module -""" +"""HoneyDB API module.""" -from .client import Client # noqa: F401 +from honeydb.api.client import DATACENTER_PROVIDERS, IPINFO_SOURCES, Client + +__all__ = ["Client", "DATACENTER_PROVIDERS", "IPINFO_SOURCES"] diff --git a/honeydb/api/client.py b/honeydb/api/client.py index 62b83a5..2894853 100644 --- a/honeydb/api/client.py +++ b/honeydb/api/client.py @@ -1,259 +1,518 @@ -""" -HoneyDB API Client +"""HoneyDB API client. + +A thin, typed wrapper around the HoneyDB REST API (https://honeydb.io). +Requests are made over a pooled :class:`requests.Session` with automatic +retries on transient failures. """ -import requests +from __future__ import annotations +from typing import Any +from urllib.parse import quote -class Client(object): - """ - Base class for making requests to the HoneyDB API. - https://honeydb.io/#threats +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from honeydb.exceptions import ( + HoneyDBAuthError, + HoneyDBError, + HoneyDBNotFoundError, + HoneyDBRateLimitError, +) + +__all__ = ["Client", "DATACENTER_PROVIDERS", "IPINFO_SOURCES"] + +#: IP list sources supported by the ``/ipinfo/`` endpoints. +IPINFO_SOURCES: tuple[str, ...] = ( + "bogon", + "tor", + "sansip", + "ciarmy", + "et-compromised", + "project-honeypot", + "pallebone", + "threatfox", + "blocklist_net_ua", +) + +#: Datacenter/cloud providers supported by the ``/datacenter/`` endpoint. +DATACENTER_PROVIDERS: tuple[str, ...] = ( + "aws", + "azure", + "azure/china", + "azure/germany", + "azure/gov", + "cloudflare", + "gcp", + "ibm", + "oracle", +) + +JSON = Any + + +class Client: + """Client for the HoneyDB API. + + Args: + api_id: Your HoneyDB API ID. + api_key: Your HoneyDB API key. + timeout: Per-request timeout in seconds. + base_url: Base URL of the API (override for testing/proxies). + session: An existing :class:`requests.Session` to reuse. If omitted, a + pooled session with retries is created and owned by this client. + retries: Number of automatic retries for transient errors (429/5xx). + + Example: + >>> from honeydb import Client + >>> with Client("api_id", "api_key") as honeydb: + ... hosts = honeydb.bad_hosts() """ - base_url = "https://honeydb.io/api" - - api_id = None - api_key = None - ep_bad_hosts = "/bad-hosts" - ep_ip_history = "/ip-history" - ep_payload_history = "/payload-history" - ep_sensor_data_count = "/sensor-data/count" - ep_sensor_data = "/sensor-data" - ep_services = "/services" - ep_stats = "/stats" - ep_stats_asn = "/stats/asn" - ep_nodes = "/nodes" - ep_netinfo_lookup = "/netinfo/lookup" - ep_netinfo_network_addresses = "/netinfo/network-addresses" - ep_netinfo_prefixes = "/netinfo/prefixes" - ep_netinfo_as_name = "/netinfo/as-name" - ep_netinfo_geolocation = "/netinfo/geolocation" - ep_datacenter = "/datacenter" - - def __init__(self, api_id, api_key): - """ - Return a HoneyDB object - """ + DEFAULT_BASE_URL = "https://honeydb.io/api" + + def __init__( + self, + api_id: str, + api_key: str, + *, + timeout: float = 30.0, + base_url: str = DEFAULT_BASE_URL, + session: requests.Session | None = None, + retries: int = 3, + ) -> None: self.api_id = api_id self.api_key = api_key + self.timeout = timeout + self.base_url = base_url.rstrip("/") + + self._owns_session = session is None + self.session = session or self._build_session(retries) + self.session.headers.update( + { + "X-HoneyDb-ApiId": self.api_id, + "X-HoneyDb-ApiKey": self.api_key, + "Accept": "application/json", + } + ) + + # -- infrastructure --------------------------------------------------- + + @staticmethod + def _build_session(retries: int) -> requests.Session: + session = requests.Session() + retry = Retry( + total=retries, + connect=retries, + read=retries, + status=retries, + backoff_factor=0.5, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=frozenset({"GET", "PUT", "DELETE"}), + respect_retry_after_header=True, + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount("https://", adapter) + session.mount("http://", adapter) + return session + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any | None = None, + ) -> JSON: + """Send a request and return the parsed JSON body. + + Raises: + HoneyDBAuthError: On HTTP 401/403. + HoneyDBNotFoundError: On HTTP 404. + HoneyDBRateLimitError: On HTTP 429. + HoneyDBError: On any other HTTP or transport error, or an + unparseable response body. + """ + url = f"{self.base_url}{path}" + try: + response = self.session.request( + method, + url, + params=params, + json=json, + timeout=self.timeout, + ) + except requests.RequestException as error: + raise HoneyDBError(f"Request to {url} failed: {error}") from error + + self._raise_for_status(response) + + # Some endpoints (e.g. datacenter feeds with no entitlement/data) return + # an empty or whitespace-only body with a success status; treat as None. + if not response.content or not response.text.strip(): + return None + try: + return response.json() + except ValueError as error: + raise HoneyDBError( + f"Response from {url} was not valid JSON", + status_code=response.status_code, + response=response.text, + ) from error + + @staticmethod + def _raise_for_status(response: requests.Response) -> None: + if response.ok: + return + + status = response.status_code + body = response.text + message = f"HoneyDB API returned HTTP {status}: {body[:200]}" + + if status in (401, 403): + raise HoneyDBAuthError(message, status_code=status, response=body) + if status == 404: + raise HoneyDBNotFoundError(message, status_code=status, response=body) + if status == 429: + retry_after = response.headers.get("Retry-After") + raise HoneyDBRateLimitError( + message, + status_code=status, + response=body, + retry_after=float(retry_after) if retry_after else None, + ) + raise HoneyDBError(message, status_code=status, response=body) + + @staticmethod + def _seg(value: Any) -> str: + """URL-encode a single path segment.""" + return quote(str(value), safe="") + + # -- lifecycle -------------------------------------------------------- + + def close(self) -> None: + """Close the underlying session if this client owns it.""" + if self._owns_session: + self.session.close() + + def __enter__(self) -> Client: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() + + # -- bad hosts -------------------------------------------------------- + + def bad_hosts(self, mydata: bool = False) -> JSON: + """Return bad hosts seen in the last 24 hours. + + Args: + mydata: If ``True``, return only data from sensors you operate. + """ + path = "/bad-hosts/mydata" if mydata else "/bad-hosts" + return self._request("GET", path) + + def bad_hosts_by_service(self, service: str, mydata: bool = False) -> JSON: + """Return bad hosts for a given service (last 24 hours). + + Args: + service: Service/protocol name to filter on. + mydata: If ``True``, return only data from sensors you operate. + """ + path = f"/bad-hosts/{self._seg(service)}" + if mydata: + path += "/mydata" + return self._request("GET", path) - def _make_request(self, endpoint, method="GET", options=None): - """ - Compose and submit API call. - """ - data = dict() + # -- ip context ------------------------------------------------------- - headers = {"X-HoneyDb-ApiId": self.api_id, "X-HoneyDb-ApiKey": self.api_key} + def ip(self, ip_address: str) -> JSON: + """Return full context for an IP (netinfo, threat, history, scanner...).""" + return self._request("GET", f"/ip/{self._seg(ip_address)}") - if options is not None: - for key in options: - data[key] = options[key] + def ip_geo(self, ip_address: str) -> JSON: + """Return geolocation information for an IP.""" + return self._request("GET", f"/ip/{self._seg(ip_address)}/geo") - url = self.base_url + endpoint - result = None + def ip_netinfo(self, ip_address: str) -> JSON: + """Return AS number, organization and location data for an IP.""" + return self._request("GET", f"/ip/{self._seg(ip_address)}/netinfo") - if method == "GET": - result = requests.get(url, params=data, headers=headers) - elif method == "POST": - headers["Content-Type"] = "application/json" - result = requests.post(url, json=data, headers=headers) - else: - raise Exception("InvalidMethod: " + str(method)) - return result.json() + def ip_threatinfo(self, ip_address: str) -> JSON: + """Return threat intel information for an IP.""" + return self._request("GET", f"/ip/{self._seg(ip_address)}/threatinfo") - def bad_hosts(self, service=None, mydata=False): - """ - Get bad-hosts - """ - endpoint = self.ep_bad_hosts + def ip_internet_scanner(self, ip_address: str) -> JSON: + """Return whether an IP is a known internet scanner.""" + return self._request("GET", f"/ip/{self._seg(ip_address)}/internet-scanner") - if service is not None: - endpoint += "/{}".format(service) + def ip_history(self, ip_address: str) -> JSON: + """Return history of an IP's interactions with the HoneyDB network.""" + return self._request("GET", f"/ip/{self._seg(ip_address)}/history") - if mydata: - endpoint += "/mydata" + def ip_cve(self, ip_address: str) -> JSON: + """Return CVEs observed from an IP.""" + return self._request("GET", f"/ip/{self._seg(ip_address)}/cve") - return self._make_request(endpoint=endpoint) + def ip_cidr(self, cidr: str) -> JSON: + """Return all IP addresses within a network range (CIDR).""" + return self._request("GET", f"/ip/cidr/{self._seg(cidr)}") - def bad_hosts_service(self, service, mydata=False): - """ - Get bad-hosts by service - """ - if mydata: - endpoint = "{}/{}/mydata".format(service, self.ep_bad_hosts) - else: - endpoint = "{}/{}".format(self.ep_bad_hosts, service) + # -- asn -------------------------------------------------------------- - return self._make_request(endpoint=endpoint) + def asn(self, as_number: int | str) -> JSON: + """Return AS number and organization name. Does not count against limits.""" + return self._request("GET", f"/asn/{self._seg(as_number)}") - def ip_history(self, ip_address: str) -> dict: - """ - Get IP History for given IP - """ - endpoint = f"{self.ep_ip_history}/{ip_address}" - - return self._make_request(endpoint=endpoint) + def asn_prefixes(self, as_number: int | str) -> JSON: + """Return IP prefixes for an ASN. Does not count against limits.""" + return self._request("GET", f"/asn/{self._seg(as_number)}/prefixes") - def payload_history( - self, year: int = None, month: int = None, hash: str = None - ) -> dict: - """ - Get payload history - """ - if hash: - endpoint = f"{self.ep_payload_history}/{hash}" + def asns(self) -> JSON: + """Return ASNs that interacted with the network in the previous day.""" + return self._request("GET", "/asns") - elif year and month: - endpoint = f"{self.ep_payload_history}/{year}/{month}" + def asns_7d(self) -> JSON: + """Return ASNs that interacted with the network in the last 7 days.""" + return self._request("GET", "/asns-7d") - elif year: - endpoint = f"{self.ep_payload_history}/{year}" + # -- cve -------------------------------------------------------------- - return self._make_request(endpoint=endpoint) + def cve(self, cve: str) -> JSON: + """Return IP history for a given CVE.""" + return self._request("GET", f"/cve/{self._seg(cve)}") - def payload_history_services(self, service: str = None) -> dict: - """ - Get payload history services - """ - endpoint = f"{self.ep_payload_history}/services" + def cve_ip(self, ip_address: str) -> JSON: + """Return CVE history for a given IP address.""" + return self._request("GET", f"/cve/ip/{self._seg(ip_address)}") - if service: - endpoint = f"{self.ep_payload_history}/{service}" + # -- sensor data ------------------------------------------------------ - return self._make_request(endpoint=endpoint) + def sensor_data( + self, + sensor_data_date: str, + from_id: int | str | None = None, + mydata: bool = True, + ) -> JSON: + """Return sensor event data for a given date. - def payload_history_remote_hosts( - self, remote_host: str = None, hash: str = None, year: int = None - ) -> dict: + Args: + sensor_data_date: Date in ``YYYY-MM-DD`` format. + from_id: Continue retrieving records after this event id (paging). + mydata: If ``True`` (default), return only data from your sensors. """ - Get payload history remote hosts + path = "/sensor-data/mydata" if mydata else "/sensor-data" + params: dict[str, Any] = {"sensor-data-date": sensor_data_date} + if from_id is not None: + params["from-id"] = from_id + return self._request("GET", path, params=params) + + def sensor_data_count(self, sensor_data_date: str, mydata: bool = True) -> JSON: + """Return a count of sensor event data for a given date. + + Args: + sensor_data_date: Date in ``YYYY-MM-DD`` format. + mydata: If ``True`` (default), count only data from your sensors. """ - endpoint = f"{self.ep_payload_history}/remote-hosts" + path = "/sensor-data/count/mydata" if mydata else "/sensor-data/count" + return self._request("GET", path, params={"sensor-data-date": sensor_data_date}) - if hash and year: - endpoint = f"{self.ep_payload_history}/{hash}/remote-hosts/{year}" + # -- services / stats ------------------------------------------------- - if remote_host: - endpoint = f"{self.ep_payload_history}/remote-hosts/{remote_host}" + def services(self) -> JSON: + """Return the network services (protocols) emulated by sensors.""" + return self._request("GET", "/services") - return self._make_request(endpoint=endpoint) + def stats(self, year: int, month: int) -> JSON: + """Return summary stats for a given year and month.""" + return self._request("GET", "/stats", params={"year": year, "month": month}) - def payload_history_attributes(self, attribute: str = None) -> dict: - """ - Get payload history attributes - """ - endpoint = f"{self.ep_payload_history}/attributes" + # -- monitors --------------------------------------------------------- - if attribute: - endpoint = f"{endpoint}/{attribute}" + def monitors(self) -> JSON: + """Return the list of current monitors.""" + return self._request("GET", "/monitors") - return self._make_request(endpoint=endpoint) + def create_monitors(self, monitors: list[dict[str, Any]]) -> JSON: + """Create one or more monitors. - def sensor_data_count(self, sensor_data_date=None, mydata=True): + Args: + monitors: A list of monitor definitions. Each is a dict such as + ``{"monitor_type": "ip_address", "ip_address": "1.2.3.4", + "description": "..."}``. Supported ``monitor_type`` values include + ``ip_address``, ``ip_range``, ``asn`` and ``string``. """ - Get sensor data count + return self._request("PUT", "/monitors", json=monitors) + + def delete_monitors(self, ids: list[int]) -> JSON: + """Delete monitors by id. + + Args: + ids: List of monitor ids to delete. """ - if mydata: - endpoint = "{}/mydata".format(self.ep_sensor_data_count) - else: - endpoint = self.ep_sensor_data_count + return self._request("DELETE", "/monitors", json={"ids": ids}) - if sensor_data_date is not None: - endpoint = "{}?sensor-data-date={}".format(endpoint, sensor_data_date) - else: - raise Exception("MissingParameter: sensor_data_date") + def monitors_logs(self) -> JSON: + """Return all monitor logs.""" + return self._request("GET", "/monitors/logs") - return self._make_request(endpoint=endpoint) + def monitors_notifications(self) -> JSON: + """Return the list of configured monitor notifications.""" + return self._request("GET", "/monitors/notifications") - def sensor_data(self, sensor_data_date=None, from_id=None, mydata=True): - """ - Get sensor data + # -- nodes ------------------------------------------------------------ + + def nodes(self, mydata: bool = False) -> JSON: + """Return honeydb-agent nodes seen in the last 3 days. + + Args: + mydata: If ``True``, return only your nodes. """ - if mydata: - endpoint = "{}/mydata".format(self.ep_sensor_data) - else: - endpoint = self.ep_sensor_data + path = "/nodes/mydata" if mydata else "/nodes" + return self._request("GET", path) - if sensor_data_date is not None: - endpoint = "{}?sensor-data-date={}".format(endpoint, sensor_data_date) + # -- payload history -------------------------------------------------- - if from_id is not None: - endpoint = "{}&from-id={}".format(endpoint, from_id) + def payload_history_remote_hosts(self) -> JSON: + """Return remote hosts from which payload data was extracted, by year.""" + return self._request("GET", "/payload-history/remote-hosts") - return self._make_request(endpoint=endpoint) + def payload_history_attributes(self) -> JSON: + """Return the list of attributes extracted from payload data.""" + return self._request("GET", "/payload-history/attributes") - def services(self): - """ - Get services - """ - endpoint = self.ep_services + def payload_history_attribute(self, attribute: str) -> JSON: + """Return historical payload data grouped by the given attribute.""" + return self._request( + "GET", f"/payload-history/attributes/{self._seg(attribute)}" + ) - return self._make_request(endpoint=endpoint) + def payload_history(self, year: int, month: int | None = None) -> JSON: + """Return payload data for a year (and optional month). - def stats(self, year: int, month: int) -> dict: - """ - Get stats + .. deprecated:: + This endpoint is deprecated by the HoneyDB API and may be removed. """ - endpoint = f"{self.ep_stats}?year={year}&month={month}" + path = f"/payload-history/{self._seg(year)}" + if month is not None: + path += f"/{self._seg(month)}" + return self._request("GET", path) - return self._make_request(endpoint=endpoint) + def payload_history_services(self) -> JSON: + """Return services from which payload data was extracted. - def stats_asn(self) -> dict: + .. deprecated:: Deprecated by the HoneyDB API. """ - Get stats-asn - """ - return self._make_request(endpoint=self.ep_stats_asn) + return self._request("GET", "/payload-history/services") - def nodes(self, mydata=False): - """ - Get nodes + def payload_history_service(self, service: str) -> JSON: + """Return payload data for a given service. + + .. deprecated:: Deprecated by the HoneyDB API. """ - if mydata: - endpoint = "{}/mydata".format(self.ep_nodes) - else: - endpoint = self.ep_nodes + return self._request("GET", f"/payload-history/{self._seg(service)}") - return self._make_request(endpoint=endpoint) + def payload_history_hash(self, hash: str) -> JSON: + """Return payload data for a given hash. - def netinfo_lookup(self, ipaddress): + .. deprecated:: Deprecated by the HoneyDB API. """ - Get netinfo for given ipaddress - """ - endpoint = "{}/{}".format(self.ep_netinfo_lookup, ipaddress) - return self._make_request(endpoint=endpoint) + return self._request("GET", f"/payload-history/{self._seg(hash)}") - def netinfo_network_addresses(self, cidr): - """ - Get network addresses for given cidr - """ - endpoint = "{}/{}".format(self.ep_netinfo_network_addresses, cidr) - return self._make_request(endpoint=endpoint) + def payload_history_hash_remote_hosts(self, hash: str, year: int) -> JSON: + """Return remote hosts for a given hash and year. - def netinfo_prefixes(self, asn): - """ - Get prefixes for given asn + .. deprecated:: Deprecated by the HoneyDB API. """ - endpoint = "{}/{}".format(self.ep_netinfo_prefixes, asn) - return self._make_request(endpoint=endpoint) + return self._request( + "GET", + f"/payload-history/{self._seg(hash)}/remote-hosts/{self._seg(year)}", + ) - def netinfo_as_name(self, asn): - """ - Get AS name for given asn - """ - endpoint = "{}/{}".format(self.ep_netinfo_as_name, asn) - return self._make_request(endpoint=endpoint) + def payload_history_remote_host(self, remote_host: str) -> JSON: + """Return payload data hashes for a given remote host. - def netinfo_geolocation(self, ipaddress): + .. deprecated:: Deprecated by the HoneyDB API. """ - Get GEO location for given ipaddress - """ - endpoint = "{}/{}".format(self.ep_netinfo_geolocation, ipaddress) - return self._make_request(endpoint=endpoint) + return self._request( + "GET", f"/payload-history/remote-hosts/{self._seg(remote_host)}" + ) + + # -- internet scanner ------------------------------------------------- - def datacenter(self, datacenter: str) -> dict: + def internet_scanner(self, ip_address: str) -> JSON: + """Return whether an IP is part of a known internet scanning service.""" + return self._request("GET", f"/internet-scanner/{self._seg(ip_address)}") + + def internet_scanner_info(self, ip_address: str) -> JSON: + """Return internet-scanner status plus details about the scanning entity.""" + return self._request("GET", f"/internet-scanner/info/{self._seg(ip_address)}") + + # -- ipinfo ----------------------------------------------------------- + + def ipinfo(self, ip_address: str) -> JSON: + """Return whether an IP is present on any of the known IP lists.""" + return self._request("GET", f"/ipinfo/{self._seg(ip_address)}") + + def ipinfo_source(self, source: str, ip_address: str) -> JSON: + """Return whether an IP is present on a specific IP list. + + Args: + source: One of :data:`IPINFO_SOURCES` (e.g. ``"tor"``, ``"bogon"``). + ip_address: The IP address to look up. + + Raises: + ValueError: If ``source`` is not a recognized IP list. """ - Get datacenter ip ranges + if source not in IPINFO_SOURCES: + raise ValueError( + f"Unknown ipinfo source {source!r}; " + f"expected one of {', '.join(IPINFO_SOURCES)}" + ) + return self._request( + "GET", f"/ipinfo/{self._seg(source)}/{self._seg(ip_address)}" + ) + + # -- netinfo (no monthly limit) -------------------------------------- + + def netinfo_lookup(self, ip_address: str) -> JSON: + """Return AS, network info and geolocation for an IP. No monthly limit.""" + return self._request("GET", f"/netinfo/lookup/{self._seg(ip_address)}") + + def netinfo_network_addresses(self, cidr: str) -> JSON: + """Return all IP addresses within a network range. No monthly limit.""" + return self._request("GET", f"/netinfo/network-addresses/{self._seg(cidr)}") + + def netinfo_prefixes(self, asn: int | str) -> JSON: + """Return all prefixes advertised for an AS network. No monthly limit.""" + return self._request("GET", f"/netinfo/prefixes/{self._seg(asn)}") + + def netinfo_as_name(self, asn: int | str) -> JSON: + """Return the name of an AS network. No monthly limit.""" + return self._request("GET", f"/netinfo/as-name/{self._seg(asn)}") + + def netinfo_geolocation(self, ip_address: str) -> JSON: + """Return geolocation information for an IP. No monthly limit.""" + return self._request("GET", f"/netinfo/geolocation/{self._seg(ip_address)}") + + # -- datacenter ------------------------------------------------------- + + def datacenter(self, provider: str) -> JSON: + """Return datacenter/cloud IP ranges for a provider. No monthly limit. + + Args: + provider: One of :data:`DATACENTER_PROVIDERS` (e.g. ``"aws"``, + ``"gcp"``, ``"azure/china"``). + + Raises: + ValueError: If ``provider`` is not recognized. """ - endpoint = f"{self.ep_datacenter}/{datacenter}" - return self._make_request(endpoint=endpoint) + if provider not in DATACENTER_PROVIDERS: + raise ValueError( + f"Unknown datacenter provider {provider!r}; " + f"expected one of {', '.join(DATACENTER_PROVIDERS)}" + ) + # provider may contain a sub-path (e.g. "azure/china"); keep the slash. + return self._request("GET", f"/datacenter/{provider}") diff --git a/honeydb/bin/honeydb b/honeydb/bin/honeydb deleted file mode 100755 index cc41c32..0000000 --- a/honeydb/bin/honeydb +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env python -""" -honeydb CLI tool - -API credentials must be exported to environment variables: -export HONEYDB_API_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -export HONEYDB_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -""" - -import os -import sys -import json -import argparse -from honeydb import api - - -def print_json_data(json_data, pretty=False): - """ - Print JSON data, with option of pretty printing - """ - if pretty: - print(json.dumps(json_data, indent=4)) - else: - print(json.dumps(json_data)) - - -def main(): - """ - Main function for HoneyDB CLI tool - """ - try: - api_id = os.environ["HONEYDB_API_ID"] - api_key = os.environ["HONEYDB_API_KEY"] - except KeyError as error: - print("Environment variable not set {}".format(str(error))) - exit() - - # Create honeydb object - honeydb = api.Client(api_id, api_key) - - # Parse arguments - parser = argparse.ArgumentParser(description="Process command line arguments.") - - parser.add_argument( - "--bad-hosts", help="Get bad hosts.", default=False, action="store_true" - ) - parser.add_argument( - "--ip-history", help="Get IP history.", default=False, action="store_true" - ) - parser.add_argument( - "--payload-history", - help="Get payload history data", - default=False, - action="store_true", - ) - parser.add_argument( - "--payload-history-services", - help="Get payload history services data", - default=False, - action="store_true", - ) - parser.add_argument( - "--payload-history-remote-hosts", - help="Get payload history remote hosts data", - default=False, - action="store_true", - ) - parser.add_argument( - "--payload-history-attributes", - help="Get payload history attributes data", - default=False, - action="store_true", - ) - parser.add_argument( - "--sensor-data-count", - help="Get sensor data count.", - default=False, - action="store_true", - ) - parser.add_argument( - "--sensor-data", help="Get sensor data.", default=False, action="store_true" - ) - parser.add_argument( - "--services", help="Get services data.", default=False, action="store_true" - ) - parser.add_argument( - "--stats", help="Get stats.", default=False, action="store_true" - ) - parser.add_argument( - "--stats-asn", help="Get stats asn.", default=False, action="store_true" - ) - parser.add_argument( - "--nodes", help="Get nodes data.", default=False, action="store_true" - ) - parser.add_argument( - "--netinfo-lookup", help="Get netinfo for IP.", type=str, default=None - ) - parser.add_argument( - "--netinfo-network-addresses", - help="Get network addresses for CIDR.", - type=str, - default=None, - ) - parser.add_argument( - "--netinfo-prefixes", - help="Get network prefixes for ASN.", - type=int, - default=None, - ) - parser.add_argument( - "--netinfo-as-name", help="Get AS name for ASN.", type=int, default=None - ) - parser.add_argument( - "--netinfo-geolocation", help="Get GEO location for IP.", type=str, default=None - ) - parser.add_argument( - "--datacenter", - help="Get datacenter IP ranges.", - choices=[ - "aws", - "azure", - "azure/china", - "azure/germany", - "azure/gov", - "gcp", - "ibm", - "oracle", - ], - default=None, - ) - parser.add_argument( - "--mydata", help="Filter on mydata.", default=False, action="store_true" - ) - parser.add_argument( - "--service", help="Filter bad-hosts by service name", type=str, default=None - ) - parser.add_argument("--date", help="Date in format YYYY-MM-DD") - parser.add_argument( - "--year", - type=int, - help="Year in the format YYYY", - ) - parser.add_argument("--month", type=int, help="Month in the format MM (1-12)") - parser.add_argument( - "--ip-address", - help="IP address to filter on.", - ) - parser.add_argument("--hash", help="Hash value.") - parser.add_argument("--attribute", help="Attribute value", type=str, default=None) - parser.add_argument("--from-id", help="ID to continue retrieving sensor data.") - parser.add_argument( - "--pretty", - help="Print JSON in pretty format.", - default=False, - action="store_true", - ) - - args = parser.parse_args() - - if not len(sys.argv) > 1: - parser.print_help() - - if args.bad_hosts: - print_json_data(honeydb.bad_hosts(args.service, args.mydata), args.pretty) - - if args.ip_history: - print_json_data(honeydb.ip_history(ip_address=args.ip_address), args.pretty) - - if args.payload_history: - if args.year is not None or args.month is not None or args.hash is not None: - print_json_data( - honeydb.payload_history( - year=args.year, month=args.month, hash=args.hash - ), - args.pretty, - ) - else: - print("ERROR: at least one required parameter not provided.") - - if args.payload_history_services: - print_json_data( - honeydb.payload_history_services(service=args.service), - args.pretty, - ) - - if args.payload_history_remote_hosts: - print_json_data( - honeydb.payload_history_remote_hosts( - remote_host=args.ip_address, hash=args.hash, year=args.year - ), - args.pretty, - ) - - if args.payload_history_attributes: - print_json_data( - honeydb.payload_history_attributes(attribute=args.attribute), - args.pretty, - ) - - if args.sensor_data_count: - if not args.date: - print("--date argument required.") - sys.exit() - - print_json_data(honeydb.sensor_data_count(args.date), args.pretty) - - if args.sensor_data: - if not args.date: - print("--date argument required.") - sys.exit() - - if not args.from_id: - print_json_data(honeydb.sensor_data(args.date), args.pretty) - else: - print_json_data( - honeydb.sensor_data(args.date, from_id=args.from_id), args.pretty - ) - - if args.services: - print_json_data(honeydb.services(), args.pretty) - - if args.stats: - print_json_data(honeydb.stats(year=args.year, month=args.month), args.pretty) - - if args.stats_asn: - print_json_data(honeydb.stats_asn(), args.pretty) - - if args.nodes: - print_json_data(honeydb.nodes(args.mydata), args.pretty) - - if args.netinfo_lookup: - print_json_data( - honeydb.netinfo_lookup(ipaddress=args.netinfo_lookup), args.pretty - ) - - if args.netinfo_network_addresses: - print_json_data( - honeydb.netinfo_network_addresses(cidr=args.netinfo_network_addresses), - args.pretty, - ) - - if args.netinfo_prefixes: - print_json_data( - honeydb.netinfo_prefixes(asn=args.netinfo_prefixes), args.pretty - ) - - if args.netinfo_as_name: - print_json_data(honeydb.netinfo_as_name(asn=args.netinfo_as_name), args.pretty) - - if args.netinfo_geolocation: - print_json_data( - honeydb.netinfo_geolocation(ipaddress=args.netinfo_geolocation), args.pretty - ) - - if args.datacenter: - print_json_data(honeydb.datacenter(datacenter=args.datacenter), args.pretty) - - -if __name__ == "__main__": - main() diff --git a/honeydb/cli.py b/honeydb/cli.py new file mode 100644 index 0000000..f4f62cd --- /dev/null +++ b/honeydb/cli.py @@ -0,0 +1,425 @@ +"""Command-line interface for the HoneyDB API. + +Credentials are read from ``--api-id`` / ``--api-key`` or, if not given, from +the ``HONEYDB_API_ID`` / ``HONEYDB_API_KEY`` environment variables. + +Run ``honeydb --help`` to see the available commands. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections.abc import Sequence +from typing import Any + +from honeydb import __version__ +from honeydb.api.client import DATACENTER_PROVIDERS, IPINFO_SOURCES, Client +from honeydb.exceptions import HoneyDBError + +PROG = "honeydb" + + +def emit(data: Any, pretty: bool) -> None: + """Print JSON data, optionally pretty-printed.""" + if pretty: + print(json.dumps(data, indent=2, sort_keys=True)) + else: + print(json.dumps(data)) + + +# -------------------------------------------------------------------------- +# Command handlers. Each takes (client, args) and returns the API response. +# -------------------------------------------------------------------------- + + +def _cmd_bad_hosts(client: Client, args: argparse.Namespace) -> Any: + if args.service: + return client.bad_hosts_by_service(args.service, mydata=args.mydata) + return client.bad_hosts(mydata=args.mydata) + + +def _cmd_ip(client: Client, args: argparse.Namespace) -> Any: + dispatch = { + "geo": client.ip_geo, + "netinfo": client.ip_netinfo, + "threatinfo": client.ip_threatinfo, + "scanner": client.ip_internet_scanner, + "history": client.ip_history, + "cve": client.ip_cve, + } + if args.view: + return dispatch[args.view](args.ip_address) + return client.ip(args.ip_address) + + +def _cmd_ip_cidr(client: Client, args: argparse.Namespace) -> Any: + return client.ip_cidr(args.cidr) + + +def _cmd_asn(client: Client, args: argparse.Namespace) -> Any: + if args.prefixes: + return client.asn_prefixes(args.as_number) + return client.asn(args.as_number) + + +def _cmd_asns(client: Client, args: argparse.Namespace) -> Any: + return client.asns_7d() if args.days == 7 else client.asns() + + +def _cmd_cve(client: Client, args: argparse.Namespace) -> Any: + return client.cve(args.cve) + + +def _cmd_cve_ip(client: Client, args: argparse.Namespace) -> Any: + return client.cve_ip(args.ip_address) + + +def _cmd_sensor_data(client: Client, args: argparse.Namespace) -> Any: + if args.count: + return client.sensor_data_count(args.date, mydata=args.mydata) + return client.sensor_data(args.date, from_id=args.from_id, mydata=args.mydata) + + +def _cmd_services(client: Client, _args: argparse.Namespace) -> Any: + return client.services() + + +def _cmd_stats(client: Client, args: argparse.Namespace) -> Any: + return client.stats(year=args.year, month=args.month) + + +def _cmd_monitors(client: Client, args: argparse.Namespace) -> Any: + if args.action == "list": + return client.monitors() + if args.action == "logs": + return client.monitors_logs() + if args.action == "notifications": + return client.monitors_notifications() + if args.action == "create": + payload = _load_json_arg(args.file, args.json) + if not isinstance(payload, list): + payload = [payload] + return client.create_monitors(payload) + if args.action == "delete": + return client.delete_monitors(args.id) + raise ValueError(f"Unknown monitors action: {args.action}") + + +def _cmd_nodes(client: Client, args: argparse.Namespace) -> Any: + return client.nodes(mydata=args.mydata) + + +def _cmd_payload_history(client: Client, args: argparse.Namespace) -> Any: + action = args.action + if action == "remote-hosts": + return client.payload_history_remote_hosts() + if action == "attributes": + if args.attribute: + return client.payload_history_attribute(args.attribute) + return client.payload_history_attributes() + if action == "year": + return client.payload_history(args.year, args.month) + if action == "services": + return client.payload_history_services() + if action == "service": + return client.payload_history_service(args.name) + if action == "hash": + return client.payload_history_hash(args.value) + raise ValueError(f"Unknown payload-history action: {action}") + + +def _cmd_internet_scanner(client: Client, args: argparse.Namespace) -> Any: + if args.info: + return client.internet_scanner_info(args.ip_address) + return client.internet_scanner(args.ip_address) + + +def _cmd_ipinfo(client: Client, args: argparse.Namespace) -> Any: + if args.source: + return client.ipinfo_source(args.source, args.ip_address) + return client.ipinfo(args.ip_address) + + +def _cmd_netinfo(client: Client, args: argparse.Namespace) -> Any: + dispatch = { + "lookup": client.netinfo_lookup, + "network-addresses": client.netinfo_network_addresses, + "prefixes": client.netinfo_prefixes, + "as-name": client.netinfo_as_name, + "geolocation": client.netinfo_geolocation, + } + return dispatch[args.action](args.value) + + +def _cmd_datacenter(client: Client, args: argparse.Namespace) -> Any: + return client.datacenter(args.provider) + + +def _load_json_arg(file: str | None, raw: str | None) -> Any: + """Load JSON payload from a file, an inline string, or stdin ('-').""" + if file: + if file == "-": + return json.load(sys.stdin) + with open(file, encoding="utf-8") as handle: + return json.load(handle) + if raw: + return json.loads(raw) + raise SystemExit("error: provide --file or --json with the monitor definition") + + +# -------------------------------------------------------------------------- +# Parser construction +# -------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + # Global options live on a shared parent parser (with SUPPRESS defaults) so + # they may be given either before or after the subcommand, e.g. both + # ``honeydb --pretty services`` and ``honeydb services --pretty`` work. + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--api-id", + default=argparse.SUPPRESS, + help="HoneyDB API ID (default: HONEYDB_API_ID env var).", + ) + common.add_argument( + "--api-key", + default=argparse.SUPPRESS, + help="HoneyDB API key (default: HONEYDB_API_KEY env var).", + ) + common.add_argument( + "-p", + "--pretty", + action="store_true", + default=argparse.SUPPRESS, + help="Pretty-print JSON output.", + ) + common.add_argument( + "--timeout", + type=float, + default=argparse.SUPPRESS, + help="Per-request timeout in seconds (default: 30).", + ) + + parser = argparse.ArgumentParser( + prog=PROG, + parents=[common], + description="CLI for the HoneyDB API (https://honeydb.io).", + ) + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + # Defaults are resolved in main() via getattr rather than set_defaults, so + # that a value given before the subcommand is not clobbered by the + # subparser (a known argparse interaction with parent parsers). + + sub = parser.add_subparsers(dest="command", metavar="") + + def add(name: str, **kwargs: Any) -> argparse.ArgumentParser: + return sub.add_parser(name, parents=[common], **kwargs) + + # bad-hosts + p = add("bad-hosts", help="Get bad hosts (last 24h).") + p.add_argument("--service", help="Filter by service/protocol name.") + p.add_argument("--mydata", action="store_true", help="Only data from your sensors.") + p.set_defaults(func=_cmd_bad_hosts) + + # ip + p = add("ip", help="Get context for an IP address.") + p.add_argument("ip_address", help="IP address to look up.") + view = p.add_mutually_exclusive_group() + for name, flag in ( + ("geo", "--geo"), + ("netinfo", "--netinfo"), + ("threatinfo", "--threatinfo"), + ("scanner", "--scanner"), + ("history", "--history"), + ("cve", "--cve"), + ): + view.add_argument( + flag, + dest="view", + action="store_const", + const=name, + help=f"Return only the {name} view.", + ) + p.set_defaults(func=_cmd_ip, view=None) + + # ip-cidr + p = add("ip-cidr", help="Get all IPs within a CIDR range.") + p.add_argument("cidr", help="Network range in CIDR notation.") + p.set_defaults(func=_cmd_ip_cidr) + + # asn + p = add("asn", help="Get ASN organization info.") + p.add_argument("as_number", help="Autonomous System number.") + p.add_argument( + "--prefixes", action="store_true", help="Return IP prefixes for the ASN." + ) + p.set_defaults(func=_cmd_asn) + + # asns + p = add("asns", help="List ASNs seen interacting with the network.") + p.add_argument( + "--days", + type=int, + choices=(1, 7), + default=1, + help="Window in days: 1 (previous day, default) or 7.", + ) + p.set_defaults(func=_cmd_asns) + + # cve + p = add("cve", help="Get IP history for a CVE.") + p.add_argument("cve", help="CVE identifier, e.g. CVE-2021-44228.") + p.set_defaults(func=_cmd_cve) + + # cve-ip + p = add("cve-ip", help="Get CVE history for an IP.") + p.add_argument("ip_address", help="IP address to look up.") + p.set_defaults(func=_cmd_cve_ip) + + # sensor-data + p = add("sensor-data", help="Get your sensor event data for a date.") + p.add_argument("--date", required=True, help="Date in YYYY-MM-DD format.") + p.add_argument("--from-id", dest="from_id", help="Continue paging from this id.") + p.add_argument( + "--count", action="store_true", help="Return a count instead of records." + ) + p.add_argument( + "--all", + dest="mydata", + action="store_false", + help="Query all sensor data instead of only yours.", + ) + p.set_defaults(func=_cmd_sensor_data, mydata=True) + + # services + p = add("services", help="List emulated services (last 24h).") + p.set_defaults(func=_cmd_services) + + # stats + p = add("stats", help="Get summary stats for a year/month.") + p.add_argument("--year", type=int, required=True, help="Year, e.g. 2024.") + p.add_argument("--month", type=int, required=True, help="Month (1-12).") + p.set_defaults(func=_cmd_stats) + + # monitors + p = add("monitors", help="Manage monitors.") + msub = p.add_subparsers(dest="action", metavar="", required=True) + + def madd(name: str, **kwargs: Any) -> argparse.ArgumentParser: + return msub.add_parser(name, parents=[common], **kwargs) + + madd("list", help="List current monitors.") + madd("logs", help="Show monitor logs.") + madd("notifications", help="Show monitor notifications.") + mc = madd("create", help="Create monitor(s) from JSON.") + mc.add_argument("--file", help="Path to a JSON file ('-' for stdin).") + mc.add_argument("--json", help="Inline JSON monitor definition.") + md = madd("delete", help="Delete monitor(s) by id.") + md.add_argument("--id", type=int, nargs="+", required=True, help="Monitor id(s).") + p.set_defaults(func=_cmd_monitors) + + # nodes + p = add("nodes", help="List honeydb-agent nodes (last 3 days).") + p.add_argument("--mydata", action="store_true", help="Only your nodes.") + p.set_defaults(func=_cmd_nodes) + + # payload-history + p = add("payload-history", help="Query payload history data.") + psub = p.add_subparsers(dest="action", metavar="", required=True) + + def padd(name: str, **kwargs: Any) -> argparse.ArgumentParser: + return psub.add_parser(name, parents=[common], **kwargs) + + padd("remote-hosts", help="Remote hosts grouped by year.") + pa = padd("attributes", help="List attributes, or one attribute.") + pa.add_argument("--attribute", help="Group historical data by this attribute.") + py = padd("year", help="[deprecated] Payload data by year/month.") + py.add_argument("year", type=int, help="Year, e.g. 2024.") + py.add_argument("month", type=int, nargs="?", help="Optional month (1-12).") + padd("services", help="[deprecated] Services with payload data.") + ps = padd("service", help="[deprecated] Payload data by service.") + ps.add_argument("name", help="Service name.") + ph = padd("hash", help="[deprecated] Payload data by hash.") + ph.add_argument("value", help="Payload hash.") + p.set_defaults(func=_cmd_payload_history) + + # internet-scanner + p = add("internet-scanner", help="Check if an IP is a scanner.") + p.add_argument("ip_address", help="IP address to look up.") + p.add_argument( + "--info", action="store_true", help="Include details about the scanner." + ) + p.set_defaults(func=_cmd_internet_scanner) + + # ipinfo + p = add("ipinfo", help="Check an IP against known IP lists.") + p.add_argument("ip_address", help="IP address to look up.") + p.add_argument( + "--source", + choices=IPINFO_SOURCES, + help="Check a single IP list instead of all.", + ) + p.set_defaults(func=_cmd_ipinfo) + + # netinfo + p = add("netinfo", help="Network info lookups (do not count against limits).") + p.add_argument( + "action", + choices=( + "lookup", + "network-addresses", + "prefixes", + "as-name", + "geolocation", + ), + help="Lookup type.", + ) + p.add_argument("value", help="IP, CIDR or ASN depending on the lookup type.") + p.set_defaults(func=_cmd_netinfo) + + # datacenter + p = add("datacenter", help="Get datacenter/cloud IP ranges.") + p.add_argument("provider", choices=DATACENTER_PROVIDERS, help="Cloud provider.") + p.set_defaults(func=_cmd_datacenter) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if not getattr(args, "func", None): + parser.print_help() + return 2 + + api_id = getattr(args, "api_id", None) or os.environ.get("HONEYDB_API_ID") + api_key = getattr(args, "api_key", None) or os.environ.get("HONEYDB_API_KEY") + pretty = getattr(args, "pretty", False) + timeout = getattr(args, "timeout", 30.0) + + if not api_id or not api_key: + parser.error( + "API credentials required: set HONEYDB_API_ID and HONEYDB_API_KEY " + "environment variables, or pass --api-id and --api-key." + ) + + try: + with Client(api_id, api_key, timeout=timeout) as client: + result = args.func(client, args) + except HoneyDBError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + emit(result, pretty) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/honeydb/exceptions.py b/honeydb/exceptions.py new file mode 100644 index 0000000..d305d41 --- /dev/null +++ b/honeydb/exceptions.py @@ -0,0 +1,54 @@ +"""Exceptions raised by the HoneyDB API client.""" + +from __future__ import annotations + + +class HoneyDBError(Exception): + """Base class for all HoneyDB API errors. + + Attributes: + status_code: HTTP status code returned by the API, if available. + response: The raw text body of the response, if available. + """ + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + response: str | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.response = response + + +class HoneyDBAuthError(HoneyDBError): + """Raised on authentication/authorization failures (HTTP 401/403). + + Usually indicates a missing or invalid ``api_id`` / ``api_key``. + """ + + +class HoneyDBNotFoundError(HoneyDBError): + """Raised when a requested resource is not found (HTTP 404).""" + + +class HoneyDBRateLimitError(HoneyDBError): + """Raised when the API rate/quota limit is exceeded (HTTP 429). + + Attributes: + retry_after: Seconds to wait before retrying, from the + ``Retry-After`` header, if provided. + """ + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + response: str | None = None, + retry_after: float | None = None, + ) -> None: + super().__init__(message, status_code=status_code, response=response) + self.retry_after = retry_after diff --git a/honeydb/py.typed b/honeydb/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4421233 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "honeydb" +version = "2.0.0" +description = "A Python API wrapper and CLI tool for HoneyDB." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "foospidy" }] +keywords = ["honeydb", "api", "wrapper", "library", "cli", "threat-intelligence"] +dependencies = ["requests>=2.31"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.urls] +Homepage = "https://honeydb.io" +Repository = "https://github.com/honeydbio/honeydb-python" + +[project.scripts] +honeydb = "honeydb.cli:main" + +[project.optional-dependencies] +dev = ["ruff", "build", "twine", "pytest", "requests-mock"] + +[tool.hatch.build.targets.wheel] +packages = ["honeydb"] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "C4"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B011"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b88034e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md diff --git a/setup.py b/setup.py deleted file mode 100644 index 142b6ef..0000000 --- a/setup.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -honeydb setup -""" - -import os -from setuptools import setup - -HERE = os.path.abspath(os.path.dirname(__file__)) - -with open(os.path.join(HERE, "README.rst")) as f: - LONG_DESC = f.read() - -setup( - name="honeydb", - version="1.5.0", - author="foospidy", - description=("A Python API wrapper and CLI tool for the HoneyDB."), - license="MIT", - keywords="wrapper library honeydb api cli", - url="https://honeydb.io", - download_url="https://github.com/honeydbio/honeydb-python", - packages=["honeydb", "honeydb.api"], - long_description=LONG_DESC, - classifiers=[ - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries :: Python Modules", - "Programming Language :: Python :: 2.7", - "License :: OSI Approved :: MIT License", - ], - install_requires=["requests"], - scripts=["honeydb/bin/honeydb"], -) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..22ae197 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,135 @@ +"""Tests for the HoneyDB CLI.""" + +from __future__ import annotations + +import json + +import pytest + +from honeydb import cli + +BASE = "https://honeydb.io/api" +CREDS = ["--api-id", "id", "--api-key", "key"] + + +def run(argv, requests_mock=None): + return cli.main([*CREDS, *argv]) + + +def test_no_command_prints_help(capsys): + assert cli.main([]) == 2 + out = capsys.readouterr().out + assert "usage:" in out + + +def test_missing_credentials_errors(monkeypatch): + monkeypatch.delenv("HONEYDB_API_ID", raising=False) + monkeypatch.delenv("HONEYDB_API_KEY", raising=False) + with pytest.raises(SystemExit): + cli.main(["services"]) + + +def test_services_command(capsys, requests_mock): + requests_mock.get(f"{BASE}/services", json=["ssh", "http"]) + assert run(["services"]) == 0 + assert json.loads(capsys.readouterr().out) == ["ssh", "http"] + + +def test_pretty_output(capsys, requests_mock): + requests_mock.get(f"{BASE}/services", json=["ssh"]) + run(["--pretty", "services"]) + assert "\n" in capsys.readouterr().out.strip() + + +def test_ip_view_flag(capsys, requests_mock): + m = requests_mock.get(f"{BASE}/ip/8.8.8.8/geo", json={"country": "US"}) + run(["ip", "8.8.8.8", "--geo"]) + assert m.last_request.path == "/api/ip/8.8.8.8/geo" + + +def test_ip_default_full_context(requests_mock): + m = requests_mock.get(f"{BASE}/ip/8.8.8.8", json={}) + run(["ip", "8.8.8.8"]) + assert m.last_request.path == "/api/ip/8.8.8.8" + + +def test_asns_days_7(requests_mock): + m = requests_mock.get(f"{BASE}/asns-7d", json=[]) + run(["asns", "--days", "7"]) + assert m.last_request.path == "/api/asns-7d" + + +def test_asn_prefixes(requests_mock): + m = requests_mock.get(f"{BASE}/asn/15169/prefixes", json=[]) + run(["asn", "15169", "--prefixes"]) + assert m.last_request.path == "/api/asn/15169/prefixes" + + +def test_monitors_list(requests_mock): + m = requests_mock.get(f"{BASE}/monitors", json=[]) + run(["monitors", "list"]) + assert m.last_request.path == "/api/monitors" + + +def test_global_flag_after_nested_subcommand(capsys, requests_mock): + # --pretty must be accepted after a nested subcommand action too. + requests_mock.get(f"{BASE}/monitors", json=[{"id": 1}]) + assert run(["monitors", "list", "--pretty"]) == 0 + assert "\n" in capsys.readouterr().out.strip() + + +def test_monitors_create_inline_json(requests_mock): + m = requests_mock.put(f"{BASE}/monitors", json={"ok": True}) + run(["monitors", "create", "--json", '{"monitor_type": "asn"}']) + assert m.last_request.json() == [{"monitor_type": "asn"}] + + +def test_monitors_delete(requests_mock): + m = requests_mock.delete(f"{BASE}/monitors", json={}) + run(["monitors", "delete", "--id", "1", "2"]) + assert m.last_request.json() == {"ids": [1, 2]} + + +def test_ipinfo_source(requests_mock): + m = requests_mock.get(f"{BASE}/ipinfo/tor/1.2.3.4", json={}) + run(["ipinfo", "1.2.3.4", "--source", "tor"]) + assert m.last_request.path == "/api/ipinfo/tor/1.2.3.4" + + +def test_datacenter(requests_mock): + m = requests_mock.get(f"{BASE}/datacenter/aws", json=[]) + run(["datacenter", "aws"]) + assert m.last_request.path == "/api/datacenter/aws" + + +def test_netinfo_lookup(requests_mock): + m = requests_mock.get(f"{BASE}/netinfo/lookup/1.2.3.4", json={}) + run(["netinfo", "lookup", "1.2.3.4"]) + assert m.last_request.path == "/api/netinfo/lookup/1.2.3.4" + + +def test_sensor_data_count(requests_mock): + m = requests_mock.get(f"{BASE}/sensor-data/count/mydata", json={"count": 1}) + run(["sensor-data", "--date", "2025-04-01", "--count"]) + assert m.last_request.qs["sensor-data-date"] == ["2025-04-01"] + + +def test_api_error_returns_1(capsys, requests_mock): + requests_mock.get(f"{BASE}/services", status_code=401, text="denied") + assert run(["services"]) == 1 + assert "error:" in capsys.readouterr().err + + +def test_global_flags_after_subcommand(capsys, requests_mock): + # --pretty / credentials must work when given after the subcommand too. + requests_mock.get(f"{BASE}/services", json=["ssh"]) + assert cli.main(["services", "--api-id", "id", "--api-key", "key", "--pretty"]) == 0 + assert "\n" in capsys.readouterr().out.strip() + + +def test_env_credentials(monkeypatch, requests_mock): + monkeypatch.setenv("HONEYDB_API_ID", "envid") + monkeypatch.setenv("HONEYDB_API_KEY", "envkey") + m = requests_mock.get(f"{BASE}/services", json=[]) + assert cli.main(["services"]) == 0 + assert m.last_request.headers["X-HoneyDb-ApiId"] == "envid" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..e2c70ab --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,181 @@ +"""Tests for the HoneyDB API client using mocked HTTP responses.""" + +from __future__ import annotations + +import pytest + +from honeydb import ( + Client, + HoneyDBAuthError, + HoneyDBError, + HoneyDBNotFoundError, + HoneyDBRateLimitError, +) + +BASE = "https://honeydb.io/api" + + +@pytest.fixture +def client(): + with Client("test-id", "test-key") as c: + yield c + + +def test_auth_headers_are_sent(client, requests_mock): + m = requests_mock.get(f"{BASE}/services", json=["ssh"]) + client.services() + assert m.last_request.headers["X-HoneyDb-ApiId"] == "test-id" + assert m.last_request.headers["X-HoneyDb-ApiKey"] == "test-key" + + +def test_bad_hosts(client, requests_mock): + requests_mock.get(f"{BASE}/bad-hosts", json=[{"remote_host": "1.2.3.4"}]) + assert client.bad_hosts() == [{"remote_host": "1.2.3.4"}] + + +def test_bad_hosts_mydata(client, requests_mock): + requests_mock.get(f"{BASE}/bad-hosts/mydata", json=[]) + assert client.bad_hosts(mydata=True) == [] + + +def test_bad_hosts_by_service_mydata(client, requests_mock): + m = requests_mock.get(f"{BASE}/bad-hosts/ssh/mydata", json=[]) + client.bad_hosts_by_service("ssh", mydata=True) + assert m.last_request.path == "/api/bad-hosts/ssh/mydata" + + +def test_ip_full_context(client, requests_mock): + requests_mock.get(f"{BASE}/ip/8.8.8.8", json={"ip": "8.8.8.8"}) + assert client.ip("8.8.8.8") == {"ip": "8.8.8.8"} + + +def test_ip_history(client, requests_mock): + m = requests_mock.get(f"{BASE}/ip/8.8.8.8/history", json=[]) + client.ip_history("8.8.8.8") + assert m.last_request.path == "/api/ip/8.8.8.8/history" + + +def test_ip_cidr(client, requests_mock): + m = requests_mock.get(f"{BASE}/ip/cidr/1.2.3.0%2F24", json=[]) + client.ip_cidr("1.2.3.0/24") + # the slash within the CIDR segment is percent-encoded so it is not + # mistaken for a path separator + assert m.last_request.path.lower() == "/api/ip/cidr/1.2.3.0%2f24" + + +def test_sensor_data_params(client, requests_mock): + m = requests_mock.get(f"{BASE}/sensor-data/mydata", json=[]) + client.sensor_data("2025-04-01", from_id=123) + assert m.last_request.qs["sensor-data-date"] == ["2025-04-01"] + assert m.last_request.qs["from-id"] == ["123"] + + +def test_sensor_data_count_all(client, requests_mock): + m = requests_mock.get(f"{BASE}/sensor-data/count", json={"count": 0}) + client.sensor_data_count("2025-04-01", mydata=False) + assert m.last_request.path == "/api/sensor-data/count" + + +def test_stats_params(client, requests_mock): + m = requests_mock.get(f"{BASE}/stats", json={}) + client.stats(2024, 1) + assert m.last_request.qs == {"year": ["2024"], "month": ["1"]} + + +def test_create_monitors_put_body(client, requests_mock): + m = requests_mock.put(f"{BASE}/monitors", json={"created": 1}) + payload = [{"monitor_type": "asn", "monitor_value": "401120"}] + client.create_monitors(payload) + assert m.last_request.method == "PUT" + assert m.last_request.json() == payload + + +def test_delete_monitors_body(client, requests_mock): + m = requests_mock.delete(f"{BASE}/monitors", json={"deleted": 2}) + client.delete_monitors([122, 123]) + assert m.last_request.method == "DELETE" + assert m.last_request.json() == {"ids": [122, 123]} + + +def test_ipinfo_source_valid(client, requests_mock): + m = requests_mock.get(f"{BASE}/ipinfo/tor/1.2.3.4", json={"tor": True}) + client.ipinfo_source("tor", "1.2.3.4") + assert m.last_request.path == "/api/ipinfo/tor/1.2.3.4" + + +def test_ipinfo_source_invalid(client): + with pytest.raises(ValueError, match="Unknown ipinfo source"): + client.ipinfo_source("nope", "1.2.3.4") + + +def test_datacenter_subpath(client, requests_mock): + m = requests_mock.get(f"{BASE}/datacenter/azure/china", json=[]) + client.datacenter("azure/china") + assert m.last_request.path == "/api/datacenter/azure/china" + + +def test_datacenter_invalid(client): + with pytest.raises(ValueError, match="Unknown datacenter provider"): + client.datacenter("digitalocean") + + +def test_netinfo_as_name(client, requests_mock): + m = requests_mock.get(f"{BASE}/netinfo/as-name/15169", json={"name": "GOOGLE"}) + assert client.netinfo_as_name(15169) == {"name": "GOOGLE"} + assert m.last_request.path == "/api/netinfo/as-name/15169" + + +@pytest.mark.parametrize( + ("status", "exc"), + [ + (401, HoneyDBAuthError), + (403, HoneyDBAuthError), + (404, HoneyDBNotFoundError), + (429, HoneyDBRateLimitError), + (500, HoneyDBError), + ], +) +def test_error_mapping(client, requests_mock, status, exc): + requests_mock.get(f"{BASE}/services", status_code=status, text="boom") + with pytest.raises(exc) as info: + client.services() + assert info.value.status_code == status + + +def test_rate_limit_retry_after(client, requests_mock): + requests_mock.get( + f"{BASE}/services", + status_code=429, + text="slow down", + headers={"Retry-After": "30"}, + ) + with pytest.raises(HoneyDBRateLimitError) as info: + client.services() + assert info.value.retry_after == 30.0 + + +def test_invalid_json_raises(client, requests_mock): + requests_mock.get(f"{BASE}/services", text="not json") + with pytest.raises(HoneyDBError, match="not valid JSON"): + client.services() + + +def test_empty_body_returns_none(client, requests_mock): + requests_mock.get(f"{BASE}/services", content=b"") + assert client.services() is None + + +def test_whitespace_body_returns_none(client, requests_mock): + # datacenter feeds can return a whitespace-only body on success. + requests_mock.get(f"{BASE}/datacenter/aws", text="\n\n") + assert client.datacenter("aws") is None + + +def test_provided_session_not_closed(): + import requests + + session = requests.Session() + client = Client("id", "key", session=session) + client.close() + # A session we didn't create must remain usable. + assert session.adapters # not closed/cleared by us