diff --git a/example-configurations/bloodhound-community/.dlt-example/config.toml b/example-configurations/bloodhound-community/.dlt-example/config.toml index e1b7caa..a4c4dbe 100644 --- a/example-configurations/bloodhound-community/.dlt-example/config.toml +++ b/example-configurations/bloodhound-community/.dlt-example/config.toml @@ -3,6 +3,13 @@ http_show_error_body = true log_cli_level = "WARNING" log_rotate_when = "midnight" + +# HTTP retry/backoff for source requests; rides out API rate limits +# (e.g. GitHub during large collections) instead of failing the run. +request_max_attempts = 15 +request_backoff_factor = 1.3 +request_max_retry_delay = 900 + # Default: logs are set to human readable text # To switch to structured JSON instead, uncomment the line below (must be uppercase "JSON") # log_format = "JSON" diff --git a/example-configurations/bloodhound-enterprise/.dlt-example/config.toml b/example-configurations/bloodhound-enterprise/.dlt-example/config.toml index 584e4b4..31e03b2 100644 --- a/example-configurations/bloodhound-enterprise/.dlt-example/config.toml +++ b/example-configurations/bloodhound-enterprise/.dlt-example/config.toml @@ -3,6 +3,13 @@ http_show_error_body = true log_cli_level = "WARNING" log_rotate_when = "midnight" + +# HTTP retry/backoff for source requests; rides out API rate limits +# (e.g. GitHub during large collections) instead of failing the run. +request_max_attempts = 15 +request_backoff_factor = 1.3 +request_max_retry_delay = 900 + # Default: logs are set to human readable text # To switch to structured JSON instead, uncomment the line below (must be uppercase "JSON") # log_format = "JSON" diff --git a/pyproject.toml b/pyproject.toml index 78d33cd..8c23635 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,15 +23,15 @@ dependencies = [ [project.optional-dependencies] all = [ - "openhound-jamf==0.2.2", - "openhound-github==0.3.6", + "openhound-jamf==0.2.3", + "openhound-github==0.3.7", "openhound-okta==0.1.6", ] jamf = [ - "openhound-jamf==0.2.2", + "openhound-jamf==0.2.3", ] github = [ - "openhound-github==0.3.6" + "openhound-github==0.3.7" ] okta = [ diff --git a/src/openhound/__init__.py b/src/openhound/__init__.py index e69de29..26bb1fd 100644 --- a/src/openhound/__init__.py +++ b/src/openhound/__init__.py @@ -0,0 +1,6 @@ +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("openhound") +except PackageNotFoundError: + __version__ = "unknown" diff --git a/src/openhound/core/clients/bloodhound.py b/src/openhound/core/clients/bloodhound.py index a70dff1..1906270 100644 --- a/src/openhound/core/clients/bloodhound.py +++ b/src/openhound/core/clients/bloodhound.py @@ -5,12 +5,13 @@ import logging from abc import ABC, abstractmethod from datetime import timedelta -from importlib.metadata import version import requests from dlt.common import json from dlt.common.exceptions import DltException +import openhound + from .models import ( AssetGroupsTags, CustomNodes, @@ -23,9 +24,6 @@ logger = logging.getLogger(__name__) -__version__ = version("openhound") - - class BloodHoundHTTPError(DltException): def __init__(self, reason: str, code: int): self.reason = reason @@ -179,7 +177,7 @@ def request( sig = base64.b64encode(digester.digest()).decode() headers = { - "User-Agent": f"openhound/{__version__}", + "User-Agent": f"openhound/{openhound.__version__}", "Authorization": f"bhesignature {self.token_id}", "RequestDate": datetime_formatted, "Signature": sig, @@ -207,7 +205,7 @@ def request( extra_headers: dict[str, str] | None = None, ): headers = { - "User-Agent": f"openhound/{__version__}", + "User-Agent": f"openhound/{openhound.__version__}", "Content-Type": "application/json", "Authorization": f"Bearer {self.token}", } diff --git a/src/openhound/core/clients/bloodhound_enterprise.py b/src/openhound/core/clients/bloodhound_enterprise.py index a76d3cb..a5d31c4 100644 --- a/src/openhound/core/clients/bloodhound_enterprise.py +++ b/src/openhound/core/clients/bloodhound_enterprise.py @@ -1,7 +1,9 @@ import gzip import json +import socket from enum import Enum +import openhound from openhound.core.clients.bloodhound import BloodHound from openhound.core.clients.models.jobs import ( JobsAvailable, @@ -52,3 +54,27 @@ def ingest(self, data: str) -> None: self.request( method="POST", path=path, body=compressed_data, extra_headers=headers ) + + def update_client_metadata(self) -> None: + path = "/api/v2/clients/update" + try: + hostname = socket.gethostname() + except OSError: + hostname = "unknown" + + if hostname == "unknown": + ip_address = "unknown" + else: + try: + ip_address = socket.gethostbyname(hostname) + except OSError: + ip_address = "unknown" + + payload = { + "Address": ip_address, + "Hostname": hostname, + "Version": openhound.__version__, + } + body = json.dumps(payload) + + self.request(method="PUT", path=path, body=body.encode()) diff --git a/src/openhound/core/logging.py b/src/openhound/core/logging.py index a53233b..2582719 100644 --- a/src/openhound/core/logging.py +++ b/src/openhound/core/logging.py @@ -5,16 +5,14 @@ import sys import time from enum import Enum -from importlib.metadata import version from logging.handlers import TimedRotatingFileHandler from pathlib import Path import dlt +import openhound from rich.console import Console from rich.logging import RichHandler -__version__ = version("openhound") - VALID_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] VALID_FORMATS = ["json", "text"] @@ -140,7 +138,7 @@ def format(self, record: logging.LogRecord) -> str: "function": record.funcName, "line": record.lineno, "message": record.getMessage(), - "openhound_version": __version__, + "openhound_version": openhound.__version__, } if record.exc_info: @@ -209,7 +207,7 @@ def format(self, record: logging.LogRecord) -> str: Returns: str: A formatted string for Rich logging """ - log_fmt = f"time={self.formatTime(record, '%Y-%m-%d %H:%M:%S')}, msg={record.getMessage()} (openhound_version={__version__})" + log_fmt = f"time={self.formatTime(record, '%Y-%m-%d %H:%M:%S')}, msg={record.getMessage()} (openhound_version={openhound.__version__})" return log_fmt diff --git a/src/openhound/scheduler/service.py b/src/openhound/scheduler/service.py index cb25ac5..2b24643 100644 --- a/src/openhound/scheduler/service.py +++ b/src/openhound/scheduler/service.py @@ -236,6 +236,12 @@ def start(self) -> None: logger.info( f"Service started, monitoring {self.bhe_uri} every {self.interval} seconds." ) + try: + self.client.update_client_metadata() + + except Exception as err: + logger.exception(f"Unable to update client metadata: {err}") + try: while not self.exit: self._poll() diff --git a/tests/test_bhe_job_scheduling.py b/tests/test_bhe_job_scheduling.py index 13ea67c..6e712a0 100644 --- a/tests/test_bhe_job_scheduling.py +++ b/tests/test_bhe_job_scheduling.py @@ -9,6 +9,7 @@ from fastapi import FastAPI, Request, Response from fastapi.testclient import TestClient +from openhound.core.clients import bloodhound_enterprise from openhound.core.clients.bloodhound_enterprise import JobStatus from openhound.core.models.graph import Graph from openhound.scheduler import service as scheduler_service @@ -40,6 +41,7 @@ def mock_bloodhound_api(): app.state.job_ended = False app.state.end_payload = None app.state.start_payload = None + app.state.client_update_payload = None app.state.ingested_edges = 0 app.state.ingested_nodes = 0 @@ -74,6 +76,11 @@ async def ingest(request: Request): app.state.ingested_edges += len(validate_graph.graph.edges) return {"status": "success"} + @app.put("/api/v2/clients/update") + async def update_client(body: dict): + app.state.client_update_payload = body + return {"status": "success"} + return TestClient(app) @@ -104,6 +111,8 @@ def mock_request(method, url, **kwargs): return mock_bloodhound_api.get(path) if method.upper() == "POST": return mock_bloodhound_api.post(path, **kwargs) + if method.upper() == "PUT": + return mock_bloodhound_api.put(path, **kwargs) raise AssertionError(f"Unhandled method: {method}") @@ -118,6 +127,63 @@ def mock_request(method, url, **kwargs): ) +def test_client_update_sends_metadata(mock_service, mock_bloodhound_api, monkeypatch): + monkeypatch.setattr(bloodhound_enterprise.openhound, "__version__", "1.2.3") + monkeypatch.setattr(bloodhound_enterprise.socket, "gethostname", lambda: "test-host") + monkeypatch.setattr( + bloodhound_enterprise.socket, + "gethostbyname", + lambda hostname: "192.0.2.10", + ) + + mock_service.client.update_client_metadata() + + assert mock_bloodhound_api.app.state.client_update_payload == { + "Address": "192.0.2.10", + "Hostname": "test-host", + "Version": "1.2.3", + } + + +def test_client_update_uses_unknown_when_hostname_lookup_fails( + mock_service, mock_bloodhound_api, monkeypatch +): + monkeypatch.setattr(bloodhound_enterprise.openhound, "__version__", "1.2.3") + + def raise_error(): + raise OSError("hostname unavailable") + + monkeypatch.setattr(bloodhound_enterprise.socket, "gethostname", raise_error) + + mock_service.client.update_client_metadata() + + assert mock_bloodhound_api.app.state.client_update_payload == { + "Address": "unknown", + "Hostname": "unknown", + "Version": "1.2.3", + } + + +def test_client_update_uses_unknown_when_ip_lookup_fails( + mock_service, mock_bloodhound_api, monkeypatch +): + monkeypatch.setattr(bloodhound_enterprise.openhound, "__version__", "1.2.3") + monkeypatch.setattr(bloodhound_enterprise.socket, "gethostname", lambda: "test-host") + + def raise_error(hostname: str): + raise OSError(f"{hostname} unavailable") + + monkeypatch.setattr(bloodhound_enterprise.socket, "gethostbyname", raise_error) + + mock_service.client.update_client_metadata() + + assert mock_bloodhound_api.app.state.client_update_payload == { + "Address": "unknown", + "Hostname": "test-host", + "Version": "1.2.3", + } + + def test_jobs_starts_new_job(mock_service, mock_bloodhound_api): """Runs _check_jobs and checks if the new job is started when available.""" diff --git a/uv.lock b/uv.lock index 5dba5b3..bd8f2c9 100644 --- a/uv.lock +++ b/uv.lock @@ -1262,10 +1262,10 @@ requires-dist = [ { name = "griffe-fieldz", specifier = ">=0.5.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.0" }, - { name = "openhound-github", marker = "extra == 'all'", specifier = "==0.3.6" }, - { name = "openhound-github", marker = "extra == 'github'", specifier = "==0.3.6" }, - { name = "openhound-jamf", marker = "extra == 'all'", specifier = "==0.2.2" }, - { name = "openhound-jamf", marker = "extra == 'jamf'", specifier = "==0.2.2" }, + { name = "openhound-github", marker = "extra == 'all'", specifier = "==0.3.7" }, + { name = "openhound-github", marker = "extra == 'github'", specifier = "==0.3.7" }, + { name = "openhound-jamf", marker = "extra == 'all'", specifier = "==0.2.3" }, + { name = "openhound-jamf", marker = "extra == 'jamf'", specifier = "==0.2.3" }, { name = "openhound-okta", marker = "extra == 'all'", specifier = "==0.1.6" }, { name = "openhound-okta", marker = "extra == 'okta'", specifier = "==0.1.6" }, { name = "psutil", specifier = ">=7.2.1" }, @@ -1308,24 +1308,24 @@ wheels = [ [[package]] name = "openhound-github" -version = "0.3.6" +version = "0.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joserfc" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/cd/42667b45c47497c7e09f12293ddce7d1c229c1f7f5b91f3660ff75f6396a/openhound_github-0.3.6.tar.gz", hash = "sha256:fbd82bffd54336c14a4c20145b3b94a4c863ce56ac285e0da4ee32f14dd395ac", size = 3569197, upload-time = "2026-06-24T20:35:20.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/1b/9d2d22e025978bd4dcad377de059f6a98debd1dff094531322bc28d81d6f/openhound_github-0.3.7.tar.gz", hash = "sha256:47575f0ed8d85b56959efe89f8cfd65b7908e1bee1caaae6830a99ab3cea2b0d", size = 3569944, upload-time = "2026-06-26T22:32:37.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/fc/afbb64a43d378753f3455d742e7b31b8b51a37bd1a5c304f03bd7b444d54/openhound_github-0.3.6-py3-none-any.whl", hash = "sha256:5c8d887fd68379ace677a640f2ee5a5b87fccd1e9591f298840a15aba2ee83f7", size = 120686, upload-time = "2026-06-24T20:35:19.212Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/d3104d7f9f8d7a8435aff788f13f09c7e1e8e82908b98e40ed0dc0b9939e/openhound_github-0.3.7-py3-none-any.whl", hash = "sha256:ca6c161a0a1cb7fb42ebaa9f2dc1b133325cdf4e8fed26e221afe41645745ca3", size = 120755, upload-time = "2026-06-26T22:32:36.541Z" }, ] [[package]] name = "openhound-jamf" -version = "0.2.2" +version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/c0/356e99c29ffeab7770e91ff26c7ba90761f47b17c1a593df65c7a9229826/openhound_jamf-0.2.2.tar.gz", hash = "sha256:91ae72090df8d5c377172b7f2cc7589a3e38439d4b3a9b0f5ca1032b40803d46", size = 3416191, upload-time = "2026-06-23T21:19:42.83Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fe/8eb116995c48dbb27cc48780b172c15a4c7d344cdac871c35a30074af7a1/openhound_jamf-0.2.3.tar.gz", hash = "sha256:e549a9ac49eaa56a4d83fda105b056b291cb9017da4c852c3788d629ece66211", size = 3487290, upload-time = "2026-06-29T19:37:16.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/41/3256a6cd08e99f3cbb88a589b8e6eec2e039afe9ea957165b5e957e039ec/openhound_jamf-0.2.2-py3-none-any.whl", hash = "sha256:161bbad151a5fe614b8666bfb79f543ee92b355b1ee0d0964304344296d338d9", size = 32133, upload-time = "2026-06-23T21:19:41.435Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0f/a8a18b925e965127543eca5afb1dbdd6d2bea39e1d88010f5d79e8d77df3/openhound_jamf-0.2.3-py3-none-any.whl", hash = "sha256:8483b92de0a8faa0d70fd6cdb9f33cc139fecfe585cfce01e97d4c1d94072b46", size = 32892, upload-time = "2026-06-29T19:37:15.276Z" }, ] [[package]]