diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a75f7a..43575ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up python uses: actions/setup-python@v5 @@ -56,6 +58,10 @@ jobs: run: | .venv/bin/pytest tests/test_extensions_format.py -v + - name: Run BHE version boundary tests + run: | + .venv/bin/pytest tests/test_bhe_version.py -v + - name: Run BHE job scheduling test run: | .venv/bin/pytest tests/test_bhe_job_scheduling.py -v diff --git a/src/openhound/core/clients/bhe_version.py b/src/openhound/core/clients/bhe_version.py new file mode 100644 index 0000000..31d9823 --- /dev/null +++ b/src/openhound/core/clients/bhe_version.py @@ -0,0 +1,31 @@ +import re + +_BHE_PACKAGE_VERSION = re.compile( + r"^(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"(?:\.?rc(?P0|[1-9]\d*))?" + r"(?:\.?dev(?P0|[1-9]\d*))?$" +) + + +class UnsupportedBHEVersion(ValueError): + """Raised when the package version cannot be represented safely for BHE.""" + + +def render_bhe_version(package_version: str) -> str: + """Convert a supported Python package version to BHE wire format.""" + match = _BHE_PACKAGE_VERSION.fullmatch(package_version) + if match is None: + raise UnsupportedBHEVersion( + "OpenHound package version " + f"{package_version!r} cannot be reported to BloodHound Enterprise; " + "supported forms are MAJOR.MINOR.PATCH[rcN]" + ) + + release = f"v{match.group('major')}.{match.group('minor')}.{match.group('patch')}" + prerelease = [] + if (rc := match.group("rc")) is not None: + prerelease.append(f"rc{rc}") + + return f"{release}-{'.'.join(prerelease)}" if prerelease else release diff --git a/src/openhound/core/clients/bloodhound.py b/src/openhound/core/clients/bloodhound.py index 1906270..9195df4 100644 --- a/src/openhound/core/clients/bloodhound.py +++ b/src/openhound/core/clients/bloodhound.py @@ -12,6 +12,7 @@ import openhound +from .bhe_version import render_bhe_version from .models import ( AssetGroupsTags, CustomNodes, @@ -36,6 +37,8 @@ def __init__(self, reason: str, code: int): class BloodHoundClient(ABC): def __init__(self, base_uri: str = "http://localhost:8000"): self.base_uri = base_uri + self.bhe_version = render_bhe_version(openhound.__version__) + self.user_agent = f"openhound/{self.bhe_version}" @abstractmethod def request( @@ -177,7 +180,7 @@ def request( sig = base64.b64encode(digester.digest()).decode() headers = { - "User-Agent": f"openhound/{openhound.__version__}", + "User-Agent": self.user_agent, "Authorization": f"bhesignature {self.token_id}", "RequestDate": datetime_formatted, "Signature": sig, @@ -205,7 +208,7 @@ def request( extra_headers: dict[str, str] | None = None, ): headers = { - "User-Agent": f"openhound/{openhound.__version__}", + "User-Agent": self.user_agent, "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 a5d31c4..4517d1b 100644 --- a/src/openhound/core/clients/bloodhound_enterprise.py +++ b/src/openhound/core/clients/bloodhound_enterprise.py @@ -3,7 +3,6 @@ import socket from enum import Enum -import openhound from openhound.core.clients.bloodhound import BloodHound from openhound.core.clients.models.jobs import ( JobsAvailable, @@ -73,7 +72,7 @@ def update_client_metadata(self) -> None: payload = { "Address": ip_address, "Hostname": hostname, - "Version": openhound.__version__, + "Version": self.bhe_version, } body = json.dumps(payload) diff --git a/tests/test_bhe_job_scheduling.py b/tests/test_bhe_job_scheduling.py index 6e712a0..6e48363 100644 --- a/tests/test_bhe_job_scheduling.py +++ b/tests/test_bhe_job_scheduling.py @@ -9,7 +9,7 @@ from fastapi import FastAPI, Request, Response from fastapi.testclient import TestClient -from openhound.core.clients import bloodhound_enterprise +from openhound.core.clients import bloodhound, 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 @@ -86,6 +86,7 @@ async def update_client(body: dict): @pytest.fixture def mock_service(mock_bloodhound_api, monkeypatch): + monkeypatch.setattr(bloodhound.openhound, "__version__", "0.3.0rc1") """Patches requests.requests so that our mocked BloodHound API will be used for testing the service. Args: @@ -128,8 +129,9 @@ 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, "gethostname", lambda: "test-host" + ) monkeypatch.setattr( bloodhound_enterprise.socket, "gethostbyname", @@ -141,15 +143,13 @@ def test_client_update_sends_metadata(mock_service, mock_bloodhound_api, monkeyp assert mock_bloodhound_api.app.state.client_update_payload == { "Address": "192.0.2.10", "Hostname": "test-host", - "Version": "1.2.3", + "Version": "v0.3.0-rc1", } 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") @@ -160,15 +160,16 @@ def raise_error(): assert mock_bloodhound_api.app.state.client_update_payload == { "Address": "unknown", "Hostname": "unknown", - "Version": "1.2.3", + "Version": "v0.3.0-rc1", } 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") + monkeypatch.setattr( + bloodhound_enterprise.socket, "gethostname", lambda: "test-host" + ) def raise_error(hostname: str): raise OSError(f"{hostname} unavailable") @@ -180,7 +181,7 @@ def raise_error(hostname: str): assert mock_bloodhound_api.app.state.client_update_payload == { "Address": "unknown", "Hostname": "test-host", - "Version": "1.2.3", + "Version": "v0.3.0-rc1", } diff --git a/tests/test_bhe_version.py b/tests/test_bhe_version.py new file mode 100644 index 0000000..e3432bf --- /dev/null +++ b/tests/test_bhe_version.py @@ -0,0 +1,86 @@ +from unittest.mock import Mock + +import pytest + +from openhound.core.clients import bloodhound +from openhound.core.clients.bhe_version import ( + UnsupportedBHEVersion, + render_bhe_version, +) +from openhound.core.clients.bloodhound import BloodHound, BloodHoundJWT + + +@pytest.mark.parametrize( + ("package_version", "reported_version"), + [ + ("0.3.0", "v0.3.0"), + ("0.3.0rc1", "v0.3.0-rc1"), + ("0.3.0.rc1", "v0.3.0-rc1"), + ("0.3.0dev1", "v0.3.0"), + ("0.3.0.dev0", "v0.3.0"), + ("0.3.0rc2.dev3", "v0.3.0-rc2"), + ("12.34.56rc10", "v12.34.56-rc10"), + ], +) +def test_render_bhe_version(package_version, reported_version): + assert render_bhe_version(package_version) == reported_version + + +@pytest.mark.parametrize( + "package_version", + [ + "unknown", + "", + "0.3", + "v0.3.0", + "0.3.0-rc1", + "0.3.0a1", + "0.3.0b1", + "0.3.0.post1", + "0.3.0+container.1", + "1!0.3.0", + "01.3.0", + ], +) +def test_render_bhe_version_rejects_unsupported_versions(package_version): + with pytest.raises( + UnsupportedBHEVersion, + match="supported forms are MAJOR.MINOR.PATCH", + ): + render_bhe_version(package_version) + + +@pytest.mark.parametrize( + ("client_factory", "authorization"), + [ + ( + lambda: BloodHound(token_key="key", token_id="id"), + "bhesignature id", + ), + ( + lambda: BloodHoundJWT(token="jwt"), + "Bearer jwt", + ), + ], +) +def test_bhe_clients_use_reported_version(monkeypatch, client_factory, authorization): + monkeypatch.setattr(bloodhound.openhound, "__version__", "0.3.0rc1") + request = Mock(return_value=Mock(status_code=200)) + monkeypatch.setattr(bloodhound.requests, "request", request) + + client_factory().request("GET", "/test") + + headers = request.call_args.kwargs["headers"] + assert headers["User-Agent"] == "openhound/v0.3.0-rc1" + assert headers["Authorization"] == authorization + + +def test_bhe_client_rejects_unsupported_version_before_request(monkeypatch): + monkeypatch.setattr(bloodhound.openhound, "__version__", "unknown") + request = Mock() + monkeypatch.setattr(bloodhound.requests, "request", request) + + with pytest.raises(UnsupportedBHEVersion, match="'unknown'"): + BloodHound(token_key="key", token_id="id") + + request.assert_not_called()