From 9c7a3c5786c7f4413225bcfe06d4468f573f1563 Mon Sep 17 00:00:00 2001 From: danti1988 <91211347+danti1988@users.noreply.github.com> Date: Mon, 18 May 2026 18:33:45 +0100 Subject: [PATCH 1/3] Allow blank OpenGraph source kind --- modules/BloodhoundImporter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/BloodhoundImporter.py b/modules/BloodhoundImporter.py index 9420f48..a57899e 100644 --- a/modules/BloodhoundImporter.py +++ b/modules/BloodhoundImporter.py @@ -531,7 +531,7 @@ def _validate_opengraph_payload( raise ValueError( f"OpenGraph {source_name}: metadata.source_kind must be a string" ) - if 'source_kind' in metadata: + if 'source_kind' in metadata and source_kind: source_kind = self._safe_opengraph_identifier( source_kind, 'source_kind', @@ -688,8 +688,6 @@ def _stub_label_for_source_kind(self, data: dict) -> str: source_kind = metadata.get('source_kind', '') if 'source_kind' in metadata and not isinstance(source_kind, str): raise ValueError("OpenGraph metadata.source_kind must be a string") - if 'source_kind' in metadata and not source_kind: - raise ValueError("OpenGraph metadata.source_kind must be a non-empty string") if source_kind: label = safe_cypher_identifier(source_kind, 'source_kind') if label in reserved_labels(): From e29039b3036d0dd6b7dfa39e7a0c4f1800552f76 Mon Sep 17 00:00:00 2001 From: danti1988 <91211347+danti1988@users.noreply.github.com> Date: Mon, 18 May 2026 18:39:28 +0100 Subject: [PATCH 2/3] Update OpenGraph source kind test --- tests/test_importer_opengraph.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/test_importer_opengraph.py b/tests/test_importer_opengraph.py index 1a09a08..30205b4 100644 --- a/tests/test_importer_opengraph.py +++ b/tests/test_importer_opengraph.py @@ -1154,8 +1154,8 @@ def test_opengraph_rejects_present_non_string_source_kind_before_query(source_ki assert conn.queries == [] -def test_opengraph_rejects_empty_source_kind_before_query(): - conn = RecordingConnection() +def test_opengraph_treats_empty_source_kind_as_omitted(): + conn = RecordingConnection(responses=[[{"c": 0}]]) importer = BloodhoundImporter(conn) data = { "metadata": {"source_kind": ""}, @@ -1167,16 +1167,26 @@ def test_opengraph_rejects_empty_source_kind_before_query(): "properties": {}, } ], - "edges": [], + "edges": [ + { + "kind": "Jenkins_AdminTo", + "start": {"value": "jenkins-1"}, + "end": {"value": "external-1"}, + "properties": {}, + } + ], }, } - with pytest.raises(ValueError, match="metadata.source_kind"): - importer._import_opengraph_files_directly( - [("jenkins.json", data, "opengraph")] - ) + stub_labels = importer._import_opengraph_files_directly( + [("jenkins.json", data, "opengraph")] + ) - assert conn.queries == [] + assert stub_labels == {"OpenGraph_Stub"} + assert any( + "MERGE (n:`OpenGraph_Stub` {objectid: $objectid})" in q + for q, _ in conn.queries + ) def test_upload_validates_ad_companion_properties_before_filter(): From d35e064934a933d07a9aa4fd868dd895fb5c743d Mon Sep 17 00:00:00 2001 From: danti1988 <91211347+danti1988@users.noreply.github.com> Date: Mon, 18 May 2026 18:50:24 +0100 Subject: [PATCH 3/3] Align OpenGraph source kind contract --- docs/OPENGRAPH.md | 15 +++++++++------ modules/BloodhoundImporter.py | 28 ++++++++++++++++------------ tests/test_importer_opengraph.py | 5 +++-- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/OPENGRAPH.md b/docs/OPENGRAPH.md index 1b65044..926395c 100644 --- a/docs/OPENGRAPH.md +++ b/docs/OPENGRAPH.md @@ -29,8 +29,9 @@ data. Mixed imports such as `SharpHound.zip MSSQLHound.zip` are treated as a BloodHound data import because they can establish or refresh the AD baseline. `metadata` may be omitted. If present, it must be a JSON object. When -`metadata.source_kind` is present, it must be a non-empty string matching the -same safe identifier rule as labels. +`metadata.source_kind` is present, it must be a string. Empty or whitespace-only +values are treated as omitted; non-empty values must match the same safe +identifier rule as labels. If BloodHound CE analysis takes longer than the default 600 seconds, set `ADPF_BH_INGESTION_TIMEOUT_SECONDS` to a positive integer number of seconds. @@ -140,8 +141,9 @@ Property endpoint example: ## Stubs Endpoint stubs use `metadata.source_kind` as their label when it is present. If -`source_kind` is missing, the importer uses `OpenGraph_Stub`. Invalid or empty -`source_kind` values are rejected before any OpenGraph writes are attempted. +`source_kind` is missing, empty, or whitespace-only, the importer uses +`OpenGraph_Stub`. Invalid non-empty `source_kind` values are rejected before any +OpenGraph writes are attempted. Stubs are created only for `match_by: "id"` endpoints that do not supply an endpoint `kind`. A typed endpoint that does not resolve is left unmatched rather @@ -300,8 +302,9 @@ semantics. The upstream docs are: Intentional ADPathfinder differences: -- `metadata.source_kind` may be omitted. If present, it must be a non-empty safe - identifier because ADPathfinder uses it as the fallback endpoint-stub label. +- `metadata.source_kind` may be omitted. Empty or whitespace-only values are + treated as omitted. Non-empty values must be safe identifiers because + ADPathfinder uses them as fallback endpoint-stub labels. - ADPathfinder does not append `source_kind` to every imported node. - Missing or empty `node.kinds` falls back to `Base`; generic Base-only nodes are merged as `OpenGraph_Stub`. diff --git a/modules/BloodhoundImporter.py b/modules/BloodhoundImporter.py index a57899e..e3eabce 100644 --- a/modules/BloodhoundImporter.py +++ b/modules/BloodhoundImporter.py @@ -526,17 +526,7 @@ def _validate_opengraph_payload( metadata = data.get('metadata', {}) if not isinstance(metadata, dict): raise ValueError(f"OpenGraph {source_name}: metadata must be an object") - source_kind = metadata.get('source_kind', '') - if 'source_kind' in metadata and not isinstance(source_kind, str): - raise ValueError( - f"OpenGraph {source_name}: metadata.source_kind must be a string" - ) - if 'source_kind' in metadata and source_kind: - source_kind = self._safe_opengraph_identifier( - source_kind, - 'source_kind', - f"OpenGraph {source_name}: metadata.source_kind", - ) + source_kind = self._validated_source_kind(metadata, source_name) if merge_mode == 'opengraph' and source_kind in reserved_labels(): raise ValueError( @@ -604,6 +594,20 @@ def _validate_opengraph_payload( "properties must be an object" ) + def _validated_source_kind(self, metadata: dict, source_name: str) -> str: + source_kind = metadata.get('source_kind', '') + if 'source_kind' in metadata and not isinstance(source_kind, str): + raise ValueError( + f"OpenGraph {source_name}: metadata.source_kind must be a string" + ) + if not source_kind or not source_kind.strip(): + return '' + return self._safe_opengraph_identifier( + source_kind, + 'source_kind', + f"OpenGraph {source_name}: metadata.source_kind", + ) + def _validate_opengraph_endpoint( self, source_name: str, @@ -688,7 +692,7 @@ def _stub_label_for_source_kind(self, data: dict) -> str: source_kind = metadata.get('source_kind', '') if 'source_kind' in metadata and not isinstance(source_kind, str): raise ValueError("OpenGraph metadata.source_kind must be a string") - if source_kind: + if source_kind and source_kind.strip(): label = safe_cypher_identifier(source_kind, 'source_kind') if label in reserved_labels(): raise ValueError( diff --git a/tests/test_importer_opengraph.py b/tests/test_importer_opengraph.py index 30205b4..1a5d4da 100644 --- a/tests/test_importer_opengraph.py +++ b/tests/test_importer_opengraph.py @@ -1154,11 +1154,12 @@ def test_opengraph_rejects_present_non_string_source_kind_before_query(source_ki assert conn.queries == [] -def test_opengraph_treats_empty_source_kind_as_omitted(): +@pytest.mark.parametrize("source_kind", ["", " "]) +def test_opengraph_treats_blank_source_kind_as_omitted(source_kind): conn = RecordingConnection(responses=[[{"c": 0}]]) importer = BloodhoundImporter(conn) data = { - "metadata": {"source_kind": ""}, + "metadata": {"source_kind": source_kind}, "graph": { "nodes": [ {