diff --git a/README.md b/README.md index 0db2c437..a97651d9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,66 @@ Follow the docs for setup, CLI usage, and collector development: - **Convert**: The raw resources are read from disk and converted to OpenGraph nodes and edges. +## Uploading to BloodHound + +BloodHound needs two things from a collector: the extension **schema**, which describes the node and +relationship kinds, and the **collection**, which is the data itself. OpenHound can send both. + +### Configure where BloodHound is + +Put the BloodHound address in `.dlt/config.toml` and the credentials in `.dlt/secrets.toml`, under the +`destination.bloodhound` section. This is the same section the saved-search and privilege-zone uploads use. +Use an API token pair (created under *My Profile → API Key Management* in BloodHound) or a bearer token from +a signed-in session. + +```toml +# .dlt/config.toml +[destination.bloodhound] +url = "http://localhost:8080" +``` + +```toml +# .dlt/secrets.toml, with an API token pair... +[destination.bloodhound] +token_id = "..." +token_key = "..." + +# ...or with a bearer token instead +[destination.bloodhound] +token = "..." +``` + +As environment variables these are `DESTINATION__BLOODHOUND__URL`, `DESTINATION__BLOODHOUND__TOKEN_ID`, +`DESTINATION__BLOODHOUND__TOKEN_KEY`, and `DESTINATION__BLOODHOUND__TOKEN`. The token pair is used when both +halves are present; otherwise the bearer token is used. + +Enterprise users need nothing new: when `destination.bloodhound` holds no credentials, the upload commands +read the `destination.bloodhoundenterprise` section that Enterprise ingest already uses. + +### Install or update a schema + +```console +# One schema file, or a zip bundle containing schema.json +openhound schema upload ./schema.json +openhound schema upload ./my-extension-bundle.zip + +# Every .json and .zip file in a folder +openhound schema upload ./schemas + +# The schema.json that an installed collector ships next to its extension.yaml +openhound schema upload sccm + +# Check the files without contacting BloodHound +openhound schema upload ./schemas --dry-run +``` + +The command prints one line per file (`created`, `updated`, `skipped`, or `failed`) and exits with +status 1 when any file failed. BloodHound matches extensions by the `schema.name` field, so uploading a +changed file with the same name updates the existing extension. + +Collectors that want `openhound schema upload ` to work should ship their schema as `schema.json` +in the same package folder as `extension.yaml`. + ## Available extensions Extend OpenHound with pre-built extensions for other services. Additional collectors can be installed diff --git a/src/openhound/cli/schema.py b/src/openhound/cli/schema.py new file mode 100644 index 00000000..a35c7890 --- /dev/null +++ b/src/openhound/cli/schema.py @@ -0,0 +1,82 @@ +"""The ``openhound schema`` command group: install extension schemas into BloodHound.""" + +import logging + +import typer + +from openhound.core.clients.from_secrets import ( + MissingBloodHoundCredentials, + bloodhound_client_from_secrets, +) +from openhound.core.schema_upload import ( + SchemaNotFound, + find_schema_files, + upload_schema_files, +) + +logger = logging.getLogger(__name__) + +schema = typer.Typer(help="OpenGraph extension schema management") + + +@schema.callback() +def schema_group(): + """Manage OpenGraph extension schemas in BloodHound. + + This callback exists so that ``schema`` behaves as a command group with named + subcommands, such as ``openhound schema upload``, even while it has only one. Without + it, Typer would collapse the group and the subcommand name would be lost. + """ + + +@schema.command(help="Install or update extension schemas in BloodHound") +def upload( + target: str = typer.Argument( + ..., + help=( + "A schema file (.json or .zip bundle), a folder of schema files, " + "or the name of an installed collector that ships a schema.json" + ), + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Check the files and show what would be uploaded without contacting BloodHound", + ), +): + """Find the schema files, upload each one, and print a line per file. + + The command exits with status 1 when any file failed, so scripts can rely on it. + """ + try: + files = find_schema_files(target) + except SchemaNotFound as error: + typer.secho(str(error), fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + if dry_run: + # A dry run never needs credentials, so a missing configuration must not stop it. + client = None + else: + try: + client = bloodhound_client_from_secrets() + except MissingBloodHoundCredentials as error: + typer.secho(str(error), fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + results = upload_schema_files(client=client, files=files, dry_run=dry_run) # type: ignore[arg-type] + + any_failed = False + for result in results: + if result.succeeded: + typer.secho(f"{result.outcome:>8} {result.path}", fg=typer.colors.GREEN) + else: + any_failed = True + typer.secho( + f"{result.outcome:>8} {result.path}: {result.detail}", + fg=typer.colors.RED, + err=True, + ) + + if any_failed: + raise typer.Exit(code=1) diff --git a/src/openhound/core/clients/bloodhound.py b/src/openhound/core/clients/bloodhound.py index c33427bc..ed44f904 100644 --- a/src/openhound/core/clients/bloodhound.py +++ b/src/openhound/core/clients/bloodhound.py @@ -24,6 +24,12 @@ logger = logging.getLogger(__name__) +# Seconds to wait for BloodHound when installing an extension schema: the first number is for +# opening the connection, the second for each wait on the reply. Without them a stalled server +# would block `schema upload` forever. +EXTENSION_UPLOAD_CONNECT_TIMEOUT_SECONDS = 10 +EXTENSION_UPLOAD_READ_TIMEOUT_SECONDS = 120 + class BloodHoundHTTPError(DltException): def __init__(self, reason: str, code: int): @@ -134,6 +140,36 @@ def custom_node(self, body: str) -> CustomNodes: response = self.request(method="POST", path=path, body=body.encode()) return CustomNodes.model_validate(response.json()) + def upsert_extension(self, body: bytes, content_type: str) -> bool: + """Install a new OpenGraph extension schema, or update the one with the same name. + + BloodHound calls this operation an "upsert": it creates the extension when the + schema name is new and updates it when the name already exists. The request body + is either the schema as JSON, or a zip bundle that contains a ``schema.json`` file + and optional ``saved_queries.json`` and ``pz_rules.json`` files. The content type + tells BloodHound which of the two it is receiving. + + Args: + body (bytes): The raw file contents to send. + content_type (str): Either ``application/json`` or ``application/zip``. + + Returns: + bool: True when an existing extension was updated, False when a new one was created. + """ + path = "/api/v2/extensions" + response = self.request( + method="PUT", + path=path, + body=body, + extra_headers={"Content-Type": content_type}, + timeout=( + EXTENSION_UPLOAD_CONNECT_TIMEOUT_SECONDS, + EXTENSION_UPLOAD_READ_TIMEOUT_SECONDS, + ), + ) + # BloodHound answers 200 (OK) for an update and 201 (Created) for a brand new extension. + return response.status_code == 200 + @property def asset_group_tags(self) -> AssetGroupsTags: path = "/api/v2/asset-group-tags" diff --git a/src/openhound/core/clients/from_secrets.py b/src/openhound/core/clients/from_secrets.py new file mode 100644 index 00000000..292c5cef --- /dev/null +++ b/src/openhound/core/clients/from_secrets.py @@ -0,0 +1,159 @@ +"""Build a BloodHound API client from the settings stored in OpenHound's configuration. + +OpenHound keeps settings in dlt's two stores. Plain settings such as the BloodHound address +live in ``.dlt/config.toml``; credentials live in ``.dlt/secrets.toml``. Both can also be +given as environment variables. This module looks in two sections, in this order, and uses +the first one that holds credentials: + +``[destination.bloodhound]`` + The section that OpenHound's saved-search and privilege-zone uploads already use. It is + the home for BloodHound Community Edition settings. Holds ``url`` plus either an API token + pair (``token_id`` and ``token_key``, created under "My Profile > API Key Management") or + a bearer ``token`` from a signed-in session. + +``[destination.bloodhoundenterprise]`` + The section that OpenHound's Enterprise ingest and scheduler already use. Holds ``url`` + plus ``token_id`` and ``token_key``. Nothing about it changes; it is read here so that an + Enterprise user needs no extra configuration. + +Example, with the address in ``config.toml``:: + + [destination.bloodhound] + url = "http://localhost:8080" + +and the credentials in ``secrets.toml``:: + + [destination.bloodhound] + token_id = "..." + token_key = "..." + +As environment variables the same settings are ``DESTINATION__BLOODHOUND__URL``, +``DESTINATION__BLOODHOUND__TOKEN_ID``, and so on. +""" + +from dataclasses import dataclass + +import dlt + +from openhound.core.clients.bloodhound import ( + BloodHound, + BloodHoundClient, + BloodHoundJWT, +) + +# The configuration sections that may hold BloodHound settings, in the order they are tried. +SECTION_BLOODHOUND = "destination.bloodhound" +SECTION_ENTERPRISE = "destination.bloodhoundenterprise" +SECTIONS_IN_ORDER = (SECTION_BLOODHOUND, SECTION_ENTERPRISE) + +# The setting names inside each section. +SETTING_URL = "url" +SETTING_TOKEN_ID = "token_id" +SETTING_TOKEN_KEY = "token_key" +SETTING_BEARER_TOKEN = "token" + + +class MissingBloodHoundCredentials(Exception): + """Raised when the configuration does not contain enough information to reach BloodHound.""" + + +@dataclass +class BloodHoundSettings: + """The settings found in one configuration section. + + Attributes: + section: The section they came from, for example ``destination.bloodhound``. + url: The BloodHound address, or None when absent. + token_id: First half of an API token pair, or None. + token_key: Second half of an API token pair, or None. + bearer_token: A session token, or None. + """ + + section: str + url: str | None + token_id: str | None + token_key: str | None + bearer_token: str | None + + @property + def has_token_pair(self) -> bool: + return bool(self.token_id and self.token_key) + + @property + def has_credentials(self) -> bool: + return self.has_token_pair or bool(self.bearer_token) + + +def read_settings(section: str) -> BloodHoundSettings: + """Read one section's settings from the two stores. + + The address is read through the configuration store because that store sees + ``config.toml``, ``secrets.toml``, and environment variables. Credentials are read through + the secrets store, which sees ``secrets.toml`` and environment variables only, so a token + accidentally placed in ``config.toml`` is not picked up. + """ + url: str | None = dlt.config.get(f"{section}.{SETTING_URL}") + token_id: str | None = dlt.secrets.get(f"{section}.{SETTING_TOKEN_ID}") + token_key: str | None = dlt.secrets.get(f"{section}.{SETTING_TOKEN_KEY}") + bearer_token: str | None = dlt.secrets.get(f"{section}.{SETTING_BEARER_TOKEN}") + return BloodHoundSettings( + section=section, + url=url, + token_id=token_id, + token_key=token_key, + bearer_token=bearer_token, + ) + + +def bloodhound_client_from_secrets() -> BloodHoundClient: + """Create the right kind of BloodHound client from whichever section holds credentials. + + The sections are tried in the order of ``SECTIONS_IN_ORDER``. Within a section the API + token pair is preferred, because API tokens do not expire the way session tokens do. When + no section holds credentials, or the chosen section has no address, a plain-English error + names exactly which settings are needed. + + Returns: + BloodHoundClient: A client that signs every request with the configured credentials. + """ + for section in SECTIONS_IN_ORDER: + settings = read_settings(section) + if not settings.has_credentials: + continue + + if not settings.url: + raise MissingBloodHoundCredentials( + f"Credentials were found in [{section}] but it has no 'url'. Add " + f"'{section}.url' to .dlt/config.toml, or set the " + f"{environment_variable_name(section, SETTING_URL)} environment variable." + ) + + if settings.has_token_pair: + # The checks above guarantee both halves are present; the assertions tell the + # type checker the same thing. + assert settings.token_id is not None and settings.token_key is not None + return BloodHound( + token_key=settings.token_key, + token_id=settings.token_id, + bhe_uri=settings.url, + ) + + assert settings.bearer_token is not None + return BloodHoundJWT(token=settings.bearer_token, base_uri=settings.url) + + raise MissingBloodHoundCredentials( + "No BloodHound credentials are configured. Add a " + f"[{SECTION_BLOODHOUND}] section with '{SETTING_URL}' in .dlt/config.toml and either " + f"'{SETTING_TOKEN_ID}' and '{SETTING_TOKEN_KEY}' or '{SETTING_BEARER_TOKEN}' in " + f".dlt/secrets.toml. An existing [{SECTION_ENTERPRISE}] section is also accepted. " + "The same settings can be environment variables such as " + f"{environment_variable_name(SECTION_BLOODHOUND, SETTING_URL)}." + ) + + +def environment_variable_name(section: str, setting: str) -> str: + """Turn ``destination.bloodhound`` and ``url`` into ``DESTINATION__BLOODHOUND__URL``. + + This is dlt's naming rule: dots become double underscores and everything is upper case. + """ + return f"{section}.{setting}".replace(".", "__").upper() diff --git a/src/openhound/core/schema_upload.py b/src/openhound/core/schema_upload.py new file mode 100644 index 00000000..b0f5180a --- /dev/null +++ b/src/openhound/core/schema_upload.py @@ -0,0 +1,225 @@ +"""Find OpenGraph extension schema files and send them to BloodHound. + +An extension schema tells BloodHound which node kinds and relationship kinds a collector +produces, with their display names, icons, and colors. BloodHound must know the schema before +it can show collected data properly. This module does the finding and the sending; the +``openhound schema upload`` command in :mod:`openhound.cli.schema` is the thin layer on top. + +The thing to upload can be named three ways: + +* A single file: ``schema.json`` or a ``bundle.zip`` that contains a ``schema.json``. +* A folder: every ``.json`` and ``.zip`` file directly inside it is uploaded. +* The name of an installed collector, such as ``sccm``: the ``schema.json`` that the + collector ships next to its ``extension.yaml`` is uploaded. +""" + +import logging +from dataclasses import dataclass +from importlib import resources +from importlib.metadata import entry_points +from pathlib import Path + +import requests +from dlt.common import json + +from openhound.core.clients.bloodhound import BloodHoundClient, BloodHoundHTTPError + +logger = logging.getLogger(__name__) + +# The entry point group that every OpenHound collector registers itself under. The same +# value is used by the collector manager when it discovers installed collectors. +COLLECTOR_ENTRY_POINT_GROUP = "openhound.sources" + +# The file name a collector uses for the schema it ships with its code. +PACKAGED_SCHEMA_FILE_NAME = "schema.json" + +# The content types BloodHound accepts for an extension upload. +CONTENT_TYPE_JSON = "application/json" +CONTENT_TYPE_ZIP = "application/zip" + + +class SchemaNotFound(Exception): + """Raised when the path or collector name does not lead to any schema file.""" + + +class InvalidSchemaFile(Exception): + """Raised when a schema file cannot be read or is not valid JSON.""" + + +@dataclass +class SchemaUploadResult: + """What happened to one schema file. + + Attributes: + path: The file that was uploaded. + outcome: "created", "updated", "skipped" (dry run), or "failed". + detail: Extra explanation, used for failures. + """ + + path: Path + outcome: str + detail: str = "" + + @property + def succeeded(self) -> bool: + return self.outcome in ("created", "updated", "skipped") + + +def find_schema_files(target: str) -> list[Path]: + """Work out which files to upload from what the person typed. + + Args: + target (str): A file path, a folder path, or the name of an installed collector. + + Returns: + list[Path]: The schema files to upload, in a stable order. + """ + candidate = Path(target) + + if candidate.is_file(): + return [candidate] + + if candidate.is_dir(): + files = sorted( + path + for path in candidate.iterdir() + if path.is_file() and path.suffix.lower() in (".json", ".zip") + ) + if not files: + raise SchemaNotFound( + f"The folder '{candidate}' contains no .json or .zip schema files." + ) + return files + + packaged_schema = find_packaged_schema(target) + if packaged_schema is not None: + return [packaged_schema] + + raise SchemaNotFound( + f"'{target}' is not a file, not a folder, and not the name of an installed " + f"collector that ships a '{PACKAGED_SCHEMA_FILE_NAME}'. Installed collectors " + f"are: {', '.join(installed_collector_names()) or 'none'}." + ) + + +def installed_collector_names() -> list[str]: + """List the names of every collector installed in this Python environment.""" + return sorted( + entry_point.name + for entry_point in entry_points(group=COLLECTOR_ENTRY_POINT_GROUP) + ) + + +def find_packaged_schema(collector_name: str) -> Path | None: + """Locate the schema file that an installed collector ships with its code. + + A collector registers itself with a line like ``sccm = "openhound_sccm.main:app"``. + The part before the first dot, ``openhound_sccm`` here, is the Python package that holds + the collector's files. The schema is expected at ``schema.json`` inside that package, + beside the ``extension.yaml`` that OpenHound already requires there. + + Args: + collector_name (str): The registered collector name, for example ``sccm``. + + Returns: + Path | None: The schema file, or None when the collector or its schema is not installed. + """ + for entry_point in entry_points(group=COLLECTOR_ENTRY_POINT_GROUP): + if entry_point.name != collector_name: + continue + + package_name = entry_point.module.split(".")[0] + schema_location = resources.files(package_name) / PACKAGED_SCHEMA_FILE_NAME + if schema_location.is_file(): + # resources.files() can describe files inside a zipped package; converting + # through str() gives us a normal filesystem path for the ordinary case. + return Path(str(schema_location)) + + logger.warning( + f"Collector '{collector_name}' is installed but ships no " + f"'{PACKAGED_SCHEMA_FILE_NAME}' in its '{package_name}' package." + ) + return None + + return None + + +def read_schema_file(path: Path) -> tuple[bytes, str]: + """Read a schema file and decide which content type to send it as. + + JSON files are parsed once before sending so that a typo in the file produces a clear + local error instead of a confusing answer from the server. + + Returns: + tuple[bytes, str]: The raw bytes and the content type for the upload. + """ + try: + contents = path.read_bytes() + except OSError as error: + raise InvalidSchemaFile(f"Could not read '{path}': {error}") from error + + if path.suffix.lower() == ".zip": + return contents, CONTENT_TYPE_ZIP + + try: + json.loads(contents.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise InvalidSchemaFile(f"'{path}' is not valid JSON: {error}") from error + + return contents, CONTENT_TYPE_JSON + + +def upload_schema_files( + client: BloodHoundClient, + files: list[Path], + dry_run: bool = False, +) -> list[SchemaUploadResult]: + """Upload each schema file in turn and report what happened to each one. + + One bad file does not stop the others: every file gets its own result, and the caller + decides what to do when some failed. + + Args: + client (BloodHoundClient): A signed-in BloodHound client. + files (list[Path]): The files to upload. + dry_run (bool): When True, read and check every file but send nothing. + + Returns: + list[SchemaUploadResult]: One result per file, in the same order as ``files``. + """ + results: list[SchemaUploadResult] = [] + + for path in files: + try: + body, content_type = read_schema_file(path) + except InvalidSchemaFile as error: + results.append( + SchemaUploadResult(path=path, outcome="failed", detail=str(error)) + ) + continue + + if dry_run: + results.append( + SchemaUploadResult(path=path, outcome="skipped", detail="dry run") + ) + continue + + try: + updated = client.upsert_extension(body=body, content_type=content_type) + except BloodHoundHTTPError as error: + results.append( + SchemaUploadResult(path=path, outcome="failed", detail=error.reason) + ) + continue + except requests.RequestException as error: + # Connection refused, DNS failure, or a timeout: record it and carry on. + results.append( + SchemaUploadResult(path=path, outcome="failed", detail=str(error)) + ) + continue + + results.append( + SchemaUploadResult(path=path, outcome="updated" if updated else "created") + ) + + return results diff --git a/src/openhound/main.py b/src/openhound/main.py index fd21556d..6f04fbde 100644 --- a/src/openhound/main.py +++ b/src/openhound/main.py @@ -8,6 +8,7 @@ from openhound.cli.preproc import preprocess from openhound.cli.privilege_zone import privilege_zone from openhound.cli.saved_search import saved_searches +from openhound.cli.schema import schema BASE_SOUCE_PATH = Path(__file__).parent / "sources" @@ -19,6 +20,7 @@ app.add_typer(create_app, name="create") app.add_typer(saved_searches, name="searches") app.add_typer(privilege_zone, name="rules") +app.add_typer(schema, name="schema") if __name__ == "__main__": app() diff --git a/tests/test_schema_upload.py b/tests/test_schema_upload.py new file mode 100644 index 00000000..0c05b1e3 --- /dev/null +++ b/tests/test_schema_upload.py @@ -0,0 +1,423 @@ +"""Tests for ``openhound schema upload`` and the pieces underneath it. + +A small fake BloodHound API, built with FastAPI, stands in for the real server. Every request +the OpenHound client makes is redirected to it, so the tests check the real request shape +(path, method, headers, body) without needing a running BloodHound. +""" + +import json +import subprocess +import sys +import zipfile +from importlib.metadata import EntryPoint +from pathlib import Path +from urllib.parse import urlsplit + +import pytest +import requests +from fastapi import FastAPI, Request, Response +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +from openhound.cli.schema import schema as schema_cli +from openhound.core.clients.bloodhound import BloodHound, BloodHoundJWT +from openhound.core.clients.from_secrets import ( + MissingBloodHoundCredentials, + bloodhound_client_from_secrets, +) +from openhound.core.schema_upload import ( + CONTENT_TYPE_JSON, + CONTENT_TYPE_ZIP, + SchemaNotFound, + find_packaged_schema, + find_schema_files, + upload_schema_files, +) + +EXAMPLE_SCHEMA = { + "schema": { + "name": "GH", + "display_name": "GitHub", + "version": "v0.0.1", + "namespace": "GH", + }, + "node_kinds": [ + {"name": "GH_User", "display_name": "User", "is_display_kind": True} + ], + "relationship_kinds": [{"name": "GH_ContributedTo", "is_traversable": True}], +} + + +@pytest.fixture +def mock_bloodhound_api(): + """A fake BloodHound that remembers every extension it was sent. + + It answers 201 the first time a schema name is seen and 200 afterwards, exactly like + the real "upsert" endpoint. A body without a top-level "schema" key is rejected with 400. + """ + app = FastAPI() + app.state.extensions = {} + app.state.received = [] + + @app.put("/api/v2/extensions") + async def upsert_extension(request: Request): + content_type = request.headers.get("content-type", "") + body = await request.body() + app.state.received.append({"content_type": content_type, "body": body}) + + if content_type.startswith(CONTENT_TYPE_ZIP): + # The real server unpacks the bundle; here it is enough to note that it arrived. + name = "zip-bundle" + elif content_type.startswith(CONTENT_TYPE_JSON): + payload = json.loads(body) + if "schema" not in payload: + return Response(status_code=400, content="missing schema section") + name = payload["schema"]["name"] + else: + return Response(status_code=415, content="unsupported content type") + + existed = name in app.state.extensions + app.state.extensions[name] = body + return Response(status_code=200 if existed else 201) + + return TestClient(app) + + +@pytest.fixture +def mock_bh_requests(monkeypatch, mock_bloodhound_api): + """Send every ``requests.request`` call to the fake BloodHound instead of the network.""" + + def mock_request(method, url, **kwargs): + parsed = urlsplit(url) + path = parsed.path + if parsed.query: + path = f"{path}?{parsed.query}" + # The OpenHound client passes the body as ``data``; the test client calls it ``content``. + content = kwargs.pop("data", None) + kwargs.pop("timeout", None) + return mock_bloodhound_api.request( + method.upper(), path, content=content, **kwargs + ) + + monkeypatch.setattr("requests.request", mock_request) + + +# Every environment variable that any of the three configuration sections could contribute. +ALL_SETTING_VARIABLES = [ + f"DESTINATION__{section}__{setting}" + for section in ("BLOODHOUND", "BLOODHOUNDENTERPRISE") + for setting in ("URL", "TOKEN", "TOKEN_ID", "TOKEN_KEY") +] + + +@pytest.fixture +def no_credentials(monkeypatch): + """Start from a clean slate so settings on the developer's machine cannot leak into a test.""" + for name in ALL_SETTING_VARIABLES: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def bearer_credentials(no_credentials, monkeypatch): + """Configure a bearer token so the client can be built from secrets.""" + monkeypatch.setenv("DESTINATION__BLOODHOUND__URL", "http://localhost:8080") + monkeypatch.setenv("DESTINATION__BLOODHOUND__TOKEN", "test-bearer-token") + + +def write_schema(path: Path, payload: dict | None = None) -> Path: + path.write_text(json.dumps(payload or EXAMPLE_SCHEMA)) + return path + + +# --- Finding files ----------------------------------------------------------------------- + + +def test_find_schema_files_accepts_a_single_file(tmp_path): + schema_file = write_schema(tmp_path / "schema.json") + assert find_schema_files(str(schema_file)) == [schema_file] + + +def test_find_schema_files_lists_json_and_zip_in_a_folder(tmp_path): + second = write_schema(tmp_path / "b.json") + first = write_schema(tmp_path / "a.json") + bundle = tmp_path / "c.zip" + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr("schema.json", json.dumps(EXAMPLE_SCHEMA)) + (tmp_path / "notes.txt").write_text("ignored") + + assert find_schema_files(str(tmp_path)) == [first, second, bundle] + + +def test_find_schema_files_explains_an_empty_folder(tmp_path): + with pytest.raises(SchemaNotFound, match="contains no .json or .zip"): + find_schema_files(str(tmp_path)) + + +def test_find_schema_files_explains_an_unknown_target(monkeypatch): + monkeypatch.setattr("openhound.core.schema_upload.entry_points", lambda group: []) + with pytest.raises(SchemaNotFound, match="not the name of an installed collector"): + find_schema_files("does-not-exist") + + +def test_find_packaged_schema_reads_the_collectors_own_schema(tmp_path, monkeypatch): + """A collector registered as ``fake = openhound_fake.main:app`` ships schema.json in its package.""" + package_dir = tmp_path / "openhound_fake" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("") + expected = write_schema(package_dir / "schema.json") + monkeypatch.syspath_prepend(str(tmp_path)) + + fake_entry_point = EntryPoint( + name="fake", value="openhound_fake.main:app", group="openhound.sources" + ) + monkeypatch.setattr( + "openhound.core.schema_upload.entry_points", lambda group: [fake_entry_point] + ) + + assert find_packaged_schema("fake") == expected + assert find_schema_files("fake") == [expected] + assert find_packaged_schema("other") is None + + +# --- Uploading --------------------------------------------------------------------------- + + +def test_upload_creates_then_updates(mock_bh_requests, mock_bloodhound_api, tmp_path): + schema_file = write_schema(tmp_path / "schema.json") + client = BloodHoundJWT(token="t", base_uri="http://localhost:8080") + + first = upload_schema_files(client, [schema_file]) + second = upload_schema_files(client, [schema_file]) + + assert [result.outcome for result in first] == ["created"] + assert [result.outcome for result in second] == ["updated"] + assert list(mock_bloodhound_api.app.state.extensions) == ["GH"] + sent = mock_bloodhound_api.app.state.received[0] + assert sent["content_type"] == CONTENT_TYPE_JSON + assert json.loads(sent["body"]) == EXAMPLE_SCHEMA + + +def test_upload_sends_zip_bundles_with_the_zip_content_type( + mock_bh_requests, mock_bloodhound_api, tmp_path +): + bundle = tmp_path / "bundle.zip" + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr("schema.json", json.dumps(EXAMPLE_SCHEMA)) + client = BloodHoundJWT(token="t", base_uri="http://localhost:8080") + + results = upload_schema_files(client, [bundle]) + + assert results[0].outcome == "created" + assert mock_bloodhound_api.app.state.received[0]["content_type"] == CONTENT_TYPE_ZIP + + +def test_upload_reports_invalid_json_without_sending_it( + mock_bh_requests, mock_bloodhound_api, tmp_path +): + broken = tmp_path / "broken.json" + broken.write_text("{ not json") + good = write_schema(tmp_path / "good.json") + client = BloodHoundJWT(token="t", base_uri="http://localhost:8080") + + results = upload_schema_files(client, [broken, good]) + + assert results[0].outcome == "failed" + assert "not valid JSON" in results[0].detail + assert results[1].outcome == "created" + # Only the good file reached the server. + assert len(mock_bloodhound_api.app.state.received) == 1 + + +def test_upload_reports_server_rejections_per_file( + mock_bh_requests, mock_bloodhound_api, tmp_path +): + rejected = write_schema(tmp_path / "rejected.json", {"node_kinds": []}) + client = BloodHoundJWT(token="t", base_uri="http://localhost:8080") + + results = upload_schema_files(client, [rejected]) + + assert results[0].outcome == "failed" + assert "missing schema section" in results[0].detail + + +def test_upload_sends_a_bounded_timeout(monkeypatch, tmp_path): + seen = {} + + def fake_request(method, url, **kwargs): + seen.update(kwargs) + raise requests.ConnectTimeout("timed out") + + monkeypatch.setattr("requests.request", fake_request) + client = BloodHoundJWT(token="t", base_uri="http://localhost:8080") + + upload_schema_files(client, [write_schema(tmp_path / "schema.json")]) + + assert seen["timeout"] == (10, 120) + + +def test_upload_reports_network_errors_and_continues(monkeypatch, tmp_path): + calls = [] + + def fake_request(method, url, **kwargs): + calls.append(url) + raise requests.ConnectionError("connection refused") + + monkeypatch.setattr("requests.request", fake_request) + client = BloodHoundJWT(token="t", base_uri="http://localhost:8080") + first = write_schema(tmp_path / "a.json") + second = write_schema(tmp_path / "b.json") + + results = upload_schema_files(client, [first, second]) + + assert [r.outcome for r in results] == ["failed", "failed"] + assert "connection refused" in results[0].detail + assert len(calls) == 2 # the second file was still attempted + + +def test_dry_run_reads_files_but_sends_nothing( + mock_bh_requests, mock_bloodhound_api, tmp_path +): + schema_file = write_schema(tmp_path / "schema.json") + + results = upload_schema_files(client=None, files=[schema_file], dry_run=True) # type: ignore[arg-type] + + assert results[0].outcome == "skipped" + assert mock_bloodhound_api.app.state.received == [] + + +# --- Choosing credentials ---------------------------------------------------------------- + + +def test_client_from_secrets_prefers_the_api_token_pair(no_credentials, monkeypatch): + monkeypatch.setenv("DESTINATION__BLOODHOUND__URL", "http://localhost:8080") + monkeypatch.setenv("DESTINATION__BLOODHOUND__TOKEN_ID", "id") + monkeypatch.setenv("DESTINATION__BLOODHOUND__TOKEN_KEY", "key") + monkeypatch.setenv("DESTINATION__BLOODHOUND__TOKEN", "bearer") + + client = bloodhound_client_from_secrets() + + assert isinstance(client, BloodHound) + assert client.base_uri == "http://localhost:8080" + + +def test_client_from_secrets_falls_back_to_the_bearer_token(bearer_credentials): + client = bloodhound_client_from_secrets() + assert isinstance(client, BloodHoundJWT) + + +def test_client_from_secrets_reads_the_existing_enterprise_section( + no_credentials, monkeypatch +): + """An Enterprise user whose ingest already works needs no new configuration.""" + monkeypatch.setenv( + "DESTINATION__BLOODHOUNDENTERPRISE__URL", "https://tenant.example" + ) + monkeypatch.setenv("DESTINATION__BLOODHOUNDENTERPRISE__TOKEN_ID", "id") + monkeypatch.setenv("DESTINATION__BLOODHOUNDENTERPRISE__TOKEN_KEY", "key") + + client = bloodhound_client_from_secrets() + + assert isinstance(client, BloodHound) + assert client.base_uri == "https://tenant.example" + + +def test_client_from_secrets_prefers_the_bloodhound_section( + no_credentials, monkeypatch +): + monkeypatch.setenv("DESTINATION__BLOODHOUND__URL", "http://community") + monkeypatch.setenv("DESTINATION__BLOODHOUND__TOKEN", "bearer") + monkeypatch.setenv("DESTINATION__BLOODHOUNDENTERPRISE__URL", "https://enterprise") + monkeypatch.setenv("DESTINATION__BLOODHOUNDENTERPRISE__TOKEN_ID", "id") + monkeypatch.setenv("DESTINATION__BLOODHOUNDENTERPRISE__TOKEN_KEY", "key") + + client = bloodhound_client_from_secrets() + + assert client.base_uri == "http://community" + + +def test_client_from_secrets_reads_the_url_from_config_toml(no_credentials, tmp_path): + """The address may live in config.toml, as OpenHound's example configurations show. + + dlt reads its settings files once per process, so this check runs a fresh interpreter in a + folder that holds only a config.toml with the address and a secrets.toml with the token. + """ + settings_dir = tmp_path / ".dlt" + settings_dir.mkdir() + (settings_dir / "config.toml").write_text( + '[destination.bloodhound]\nurl = "http://from-config-toml"\n' + ) + (settings_dir / "secrets.toml").write_text( + '[destination.bloodhound]\ntoken = "from-secrets-toml"\n' + ) + program = ( + "from openhound.core.clients.from_secrets import bloodhound_client_from_secrets\n" + "client = bloodhound_client_from_secrets()\n" + "print(type(client).__name__, client.base_uri)\n" + ) + + completed = subprocess.run( + [sys.executable, "-c", program], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ) + + assert completed.stdout.strip() == "BloodHoundJWT http://from-config-toml" + + +def test_client_from_secrets_explains_missing_settings(no_credentials, monkeypatch): + with pytest.raises(MissingBloodHoundCredentials, match="No BloodHound credentials"): + bloodhound_client_from_secrets() + + monkeypatch.setenv("DESTINATION__BLOODHOUND__TOKEN", "bearer") + with pytest.raises(MissingBloodHoundCredentials, match="has no 'url'"): + bloodhound_client_from_secrets() + + +# --- The command line -------------------------------------------------------------------- + + +def test_cli_uploads_and_prints_one_line_per_file( + mock_bh_requests, mock_bloodhound_api, bearer_credentials, tmp_path +): + schema_file = write_schema(tmp_path / "schema.json") + + result = CliRunner().invoke(schema_cli, ["upload", str(schema_file)]) + + assert result.exit_code == 0, result.output + assert "created" in result.output + assert list(mock_bloodhound_api.app.state.extensions) == ["GH"] + + +def test_cli_dry_run_needs_no_credentials( + mock_bh_requests, mock_bloodhound_api, no_credentials, tmp_path +): + schema_file = write_schema(tmp_path / "schema.json") + + result = CliRunner().invoke(schema_cli, ["upload", str(schema_file), "--dry-run"]) + + assert result.exit_code == 0, result.output + assert "skipped" in result.output + assert mock_bloodhound_api.app.state.received == [] + + +def test_cli_exits_with_status_one_when_a_file_fails( + mock_bh_requests, mock_bloodhound_api, bearer_credentials, tmp_path +): + broken = tmp_path / "broken.json" + broken.write_text("{ not json") + + result = CliRunner().invoke(schema_cli, ["upload", str(broken)]) + + assert result.exit_code == 1 + assert "not valid JSON" in result.output + + +def test_cli_explains_an_unknown_target(bearer_credentials, monkeypatch): + monkeypatch.setattr("openhound.core.schema_upload.entry_points", lambda group: []) + + result = CliRunner().invoke(schema_cli, ["upload", "nope"]) + + assert result.exit_code == 1 + assert "not the name of an installed collector" in result.output