Skip to content

feat: add Microsoft Entra Domain Services graph support - BED-9245 - #3135

Open
martinsohn wants to merge 23 commits into
mainfrom
microsoft-entra-domain-services
Open

feat: add Microsoft Entra Domain Services graph support - BED-9245#3135
martinsohn wants to merge 23 commits into
mainfrom
microsoft-entra-domain-services

Conversation

@martinsohn

@martinsohn martinsohn commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description

Adds BloodHound graph support for Microsoft Entra Domain Services (Entra DS), connecting data collected from Microsoft Entra ID, Azure Resource Manager, and the managed Active Directory domain.

This PR:

  • Adds the AZEntraDS node, its properties, valid relationships, and graph schema metadata.
  • Ingests Entra DS resources and resource-scoped role assignments collected by AzureHound.
  • Correlates Entra users and groups with their synchronized Entra DS counterparts using normalized objectid and aadobjectid values.
  • Adds hybrid post-processing, traversability, API and UI schema support, Explore filters, help text, edge composition, and regression coverage.
  • Treats self-referential AZRunsAs relationships as non-evidence so they do not abort unrelated hybrid post-processing.
  • Treats a missing optional Domain-to-Domain-Users containment traversal as absent evidence rather than an analysis failure.

Graph model

Relationship Traversable Purpose
AZEntraDSContributor No Raw Domain Services Contributor authorization evidence
AZManageEntraDS Yes Qualified cross-plane control after the required ARM and Entra roles are satisfied
SyncedToEntraDSUser Yes Correlates an Entra user with its synchronized Entra DS user
SyncedToEntraDSGroup No Correlates an Entra group with its synchronized Entra DS group
AddEntraDSGroupMember Yes Effective Entra DS group membership control through synchronized identities and groups
EntraDSFor No Correlates an Entra DS resource with its managed AD domain
ManageEntraDSSync Yes Qualified-principal control over the broad synchronization boundary
ManageEntraDSSyncFilter Yes Filtered-sync service principal control over eligible direct group members

Motivation and Context

Relates to ticket https://specterops.atlassian.net/browse/BED-9245

Related work in other BloodHound repos:

Entra DS spans Microsoft Entra ID, Azure Resource Manager, and a Microsoft-managed Active Directory domain. BloodHound previously lacked the graph objects and relationships required to connect these identity and management planes.

The resulting gap prevented users from identifying attack paths where an Entra principal can:

  • Control an Entra DS resource or its synchronization boundary.
  • Reset or otherwise control an identity synchronized into Entra DS.
  • Control membership of an Entra group synchronized to a privileged Entra DS group.
  • Reach Tier Zero users and groups in the managed domain.

Summary by CodeRabbit

  • New Features
    • Added Microsoft Entra Domain Services visibility, including managed domains, security settings, synchronization, group membership, and management relationships.
    • Added graph icons, entity details, pathfinding, edge composition, and Azure search queries for Entra Domain Services.
    • Added analysis of effective access and synchronization relationships across Azure and hybrid environments.
  • Documentation
    • Added contextual help, abuse guidance, operational security information, and Microsoft/MITRE references for new relationships.
  • Bug Fixes
    • Prevented stale relationship data from triggering incorrect composition queries.

Hybrid post-processing treated an empty PostgreSQL containment traversal and a self-referential AZRunsAs edge as fatal, suppressing otherwise valid Entra DS relationships. Treat missing containment as absent evidence and ignore self-loops that cannot identify distinct application and service-principal endpoints. Add regressions for both live-data shapes.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request adds Microsoft Entra Domain Services support across Azure ingestion, graph schemas, post-processing, edge composition, APIs, generated schemas, searches, and the user interface. It also adds synchronization, management, membership, and related relationship types with integration and UI tests.

Changes

Entra Domain Services graph and ingestion

Layer / File(s) Summary
Graph contracts and Azure ingestion
packages/go/graphschema/..., packages/cue/..., packages/go/ein/..., cmd/api/src/database/...
Adds Entra Domain Services node, properties, relationship kinds, traversal classifications, Azure data conversion, role-assignment conversion, and database schema entries.
Azure and hybrid analysis
packages/go/analysis/azure/..., packages/go/analysis/hybrid/..., packages/go/analysis/post/...
Creates Domain Services management, synchronization, group-membership, and filtering relationships. Adds permission resolution, correlation, path composition, and post-processing integration.
API and composition routing
cmd/api/src/api/v2/..., packages/go/analysis/edgecomposition/...
Exposes Domain Services entity details and dispatches edge-composition requests to Azure and hybrid handlers.
Generated schema and UI integration
packages/go/schemagen/..., packages/javascript/bh-shared-ui/src/..., cmd/ui/src/..., schemas/valid_edges.json
Adds generated relationship definitions, graph searches, icons, edge categories, entity endpoints, help content, composition views, and UI regression tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AzureCollector
  participant Graphify
  participant PostProcessing
  participant GraphAPI
  participant WebUI

  AzureCollector->>Graphify: ingest Entra Domain Services and role assignments
  Graphify->>PostProcessing: provide EntraDS nodes and contributor relationships
  PostProcessing->>PostProcessing: resolve contributors and directory roles
  PostProcessing->>GraphAPI: create AZManageEntraDS and synchronization relationships
  WebUI->>GraphAPI: request Domain Services details and edge composition
  GraphAPI->>WebUI: return entity details and composed graph paths
Loading

Suggested labels: enhancement, api, user interface, javascript, go, documentation

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description explains the feature and motivation, but it omits testing details, change types, and the required checklist. Add a How Has This Been Tested section, select the applicable change types, and complete the checklist items, including the associated ticket.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary change: adding Microsoft Entra Domain Services graph support.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch microsoft-entra-domain-services

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added api A pull request containing changes affecting the API code. documentation Improvements or additions to documentation enhancement New feature or request go Pull requests that update go code javascript Pull requests that update javascript code user interface A pull request containing changes affecting the UI code. labels Aug 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (7)
packages/go/ein/azure_domain_service_test.go (1)

35-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Group independent variable initializations in var (...) blocks.

  • packages/go/ein/azure_domain_service_test.go#L35-L37: Group ingestTime and data in a var (...) block.
  • packages/go/ein/azure_domain_service_test.go#L135-L138: Group resourceID and principalID in a var (...) block.

As per coding guidelines, “When possible, group variable initializations in a var (...) block and hoist them to the top of the function.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/ein/azure_domain_service_test.go` around lines 35 - 37, In
TestConvertAzureDomainServiceToNode, group the ingestTime and data
initializations in a single var block at the top of the function; likewise,
group resourceID and principalID in a var block at the second affected
initialization site in packages/go/ein/azure_domain_service_test.go (lines
135-138).

Source: Coding guidelines

packages/go/analysis/azure/azure_integration_test.go (1)

1243-1256: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the assertions out of the ReadTransaction closure.

Lines 1245-1253 call require and assert inside the transaction callback. require.NoError and require.Len call t.FailNow(), which calls runtime.Goexit(). Testify documents that FailNow must run on the goroutine that runs the test function. If dawgs executes the callback on another goroutine, the exit aborts that goroutine instead of the test, and the failure is reported incorrectly.

The closure also discards err at Line 1245 instead of returning it to the transaction machinery.

Collect the edges inside the closure. Assert after require.NoError(t, err) at Line 1256.

♻️ Proposed refactor
 	var manageEdges []*graph.Relationship
 	err = suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error {
 		edges, err := ops.FetchRelationships(tx.Relationships().Filter(query.Kind(query.Relationship(), graphAzure.ManageEntraDS)))
-		require.NoError(t, err)
-		require.Len(t, edges, 2)
-		manageEdges = edges
-
-		actualSources := []graph.ID{edges[0].StartID, edges[1].StartID}
-		assert.ElementsMatch(t, []graph.ID{qualifiedUser.ID, domainServicesContributor.ID}, actualSources)
-		for _, edge := range edges {
-			assert.Equal(t, domainService.ID, edge.EndID)
-		}
-		return nil
+		if err != nil {
+			return err
+		}
+
+		manageEdges = edges
+		return nil
 	})
 	require.NoError(t, err)
+	require.Len(t, manageEdges, 2)
+
+	actualSources := make([]graph.ID, 0, len(manageEdges))
+	for _, edge := range manageEdges {
+		actualSources = append(actualSources, edge.StartID)
+		assert.Equal(t, domainService.ID, edge.EndID)
+	}
+	assert.ElementsMatch(t, []graph.ID{qualifiedUser.ID, domainServicesContributor.ID}, actualSources)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/azure/azure_integration_test.go` around lines 1243 -
1256, Update the ReadTransaction callback to only fetch and return the
relationships error while assigning the resulting edges to manageEdges; remove
all require/assert calls from the closure. After require.NoError(t, err),
validate manageEdges length, source IDs, and destination IDs on the test
goroutine, and ensure the callback returns the FetchRelationships error to the
transaction machinery.
packages/go/analysis/hybrid/hybrid.go (2)

362-362: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Group the four same-typed map parameters into a struct.

addManageEntraDSSyncEdges takes syncedToEntraDSGroupEdgeMap, entraDSForEdgeMap, manageEntraDSSyncEdgeMap, and manageEntraDSSyncFilterEdgeMap as four adjacent map[graph.ID][]graph.ID parameters. The compiler cannot detect a transposed argument at the call site in PostHybrid. A small struct makes each target explicit.

♻️ Proposed refactor
type entraDSSyncEdgeMaps struct {
	syncedToEntraDSGroups  map[graph.ID][]graph.ID
	entraDSFor             map[graph.ID][]graph.ID
	manageEntraDSSync      map[graph.ID][]graph.ID
	manageEntraDSSyncFilter map[graph.ID][]graph.ID
}

func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, entraDSAdminGroupTenantMap map[graph.ID]string, edgeMaps entraDSSyncEdgeMaps) error {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/hybrid/hybrid.go` at line 362, Introduce an
entraDSSyncEdgeMaps struct containing the four map[graph.ID][]graph.ID fields,
update addManageEntraDSSyncEdges to accept this struct instead of four separate
map parameters, and adjust the PostHybrid call site and function references to
use the corresponding named fields.

650-655: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Push the end-node filter into the relationship query.

This fetch loads every AZAddMembers and AZOwns relationship in the graph into memory, then discards all of them whose end node is not an Entra DS-synced AZGroup. azGroupToADGroups already holds the complete set of eligible end-node IDs at this point. Add the ID filter to the query so the database performs the restriction.

⚡ Proposed change
+	azGroupIDs := make([]graph.ID, 0, len(azGroupToADGroups))
+	for azGroupID := range azGroupToADGroups {
+		azGroupIDs = append(azGroupIDs, azGroupID)
+	}
+
 	memberAddEdges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
-		return query.KindIn(query.Relationship(), azure.AddMembers, azure.Owns)
+		return query.And(
+			query.KindIn(query.Relationship(), azure.AddMembers, azure.Owns),
+			query.InIDs(query.EndID(), azGroupIDs...),
+		)
 	}))

Confirm that the driver handles a large InIDs list acceptably before you apply this.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/hybrid/hybrid.go` around lines 650 - 655, Update the
relationship query used to populate memberAddEdges so it filters end-node IDs
using the eligible IDs already held by azGroupToADGroups, alongside the existing
azure.AddMembers and azure.Owns kind filter. Use the driver's supported InIDs
predicate and confirm it handles the expected list size before applying the
change; preserve the existing error handling.
packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx (1)

20-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the component and differentiate the platform guidance.

The component is named Abuse, but the file is WindowsAbuse.tsx. The body is also identical to packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx. Rename the component to WindowsAbuse. See the consolidated comment for the duplication.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx`
around lines 20 - 50, Rename the component declaration and default export in
WindowsAbuse.tsx from Abuse to WindowsAbuse, keeping the existing
Windows-specific guidance unchanged.
packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx (1)

20-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the component and remove the duplicated content.

The component is named Abuse, but the file is LinuxAbuse.tsx. The whole component body is also identical to packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx. Rename the component to LinuxAbuse for clarity. See the consolidated comment for the duplication.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx`
around lines 20 - 50, Rename the component in LinuxAbuse.tsx from Abuse to
LinuxAbuse and update its default export accordingly. Remove the duplicated
component content by reusing the shared or consolidated implementation used by
WindowsAbuse, while preserving the Linux-specific entry point and rendered
behavior.
packages/go/analysis/hybrid/hybrid_integration_test.go (1)

694-699: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Group the variable declarations into one var (...) block.

Lines 696-699 declare four variables in four separate statements. Group them into a single var (...) block at the top of the function.

♻️ Proposed refactor
 	testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
-	var syncHarness manageEntraDSSyncHarness
-	var container *graph.Node
-	var unrelatedDomainService *graph.Node
-	var unrelatedDomain *graph.Node
+	var (
+		syncHarness            manageEntraDSSyncHarness
+		container              *graph.Node
+		unrelatedDomainService *graph.Node
+		unrelatedDomain        *graph.Node
+	)

As per coding guidelines: "When possible, group variable initializations in a var (...) block and hoist them to the top of the function."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/hybrid/hybrid_integration_test.go` around lines 694 -
699, Group the declarations of testContext, syncHarness, container,
unrelatedDomainService, and unrelatedDomain into a single var (...) block at the
top of TestGetManageEntraDSSyncEdgeComposition, preserving their existing types
and initialization behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cue/bh/azure/azure.cue`:
- Around line 1192-1195: The derived relationship lists in
packages/cue/bh/azure/azure.cue are missing EntraDS kinds: at lines 1192-1195,
add SyncedToEntraDSGroup and EntraDSFor to InboundOutboundRelationshipKinds; at
lines 1034-1035, add EntraDSContributor alongside Contributor in both
ControlRelationshipKinds and InboundOutboundRelationshipKinds. Regenerate
packages/go/graphschema/azure/azure.go and
packages/javascript/bh-shared-ui/src/graphSchema.ts.

In `@packages/go/analysis/azure/domain_service.go`:
- Around line 26-40: The Azure *EntityDetails functions currently rely on
unspecified return evaluation order when returning details alongside
db.ReadTransaction. Update all 22 matching EntityDetails functions, including
DomainServiceEntityDetails, to explicitly execute the transaction, capture its
error, and return the populated details with that error afterward.

In `@packages/go/analysis/hybrid/composition.go`:
- Around line 144-158: Update the containment traversal error handling in
GetManageEntraDSSyncEdgeComposition so graph.ErrNoResultsFound is treated like
an empty containmentPaths result: continue to the next correlation path.
Preserve returning other traversal errors unchanged.

In `@packages/go/analysis/hybrid/hybrid_integration_test.go`:
- Around line 449-459: Update the relationship-fetch block around
FetchRelationships to use require.Len for the edges count assertion before
indexing edges[0]. Keep the existing assertion and edge assignment behavior
otherwise unchanged, matching the require-based block later in the test.
- Line 911: Update setupEntraDSGroupMemberHarness to use an unnamed return type
list, then declare azUser, adUser, azGroup, and adGroup inside the function body
while preserving the existing return behavior and documentation.

In `@packages/go/analysis/hybrid/hybrid.go`:
- Around line 343-355: Update addEntraDSAdminGroupTenant to retrieve both name
and tenantID through normalizedNodeProperty, treating missing properties as
absent rather than returning graph.ErrPropertyNotFound; preserve prefix matching
and normalized tenant mapping. Remove the now-redundant uppercase/trim
operations because normalizedNodeProperty handles them. Apply the same
missing-property handling to addNodeToObjectIDMap for objectid.

In `@packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts`:
- Around line 486-488: Update the description associated with the query matching
the AZManageEntraDS edge so it names AZManageEntraDS instead of
ManageEntraDSSync, while preserving the remaining description text.

In `@packages/javascript/bh-shared-ui/src/graphSchema.ts`:
- Around line 1398-1401: Add AzureRelationshipKind.SyncedToEntraDSGroup to the
AzurePathfindingEdges collection alongside the existing Entra DS relationship
kinds, preserving all current entries.

In
`@packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx`:
- Around line 227-233: Update the Cross Platform category’s edgeTypes list to
include AzureRelationshipKind.SyncedToEntraDSGroup alongside the existing Azure
relationship kinds, preserving all current entries.

---

Nitpick comments:
In `@packages/go/analysis/azure/azure_integration_test.go`:
- Around line 1243-1256: Update the ReadTransaction callback to only fetch and
return the relationships error while assigning the resulting edges to
manageEdges; remove all require/assert calls from the closure. After
require.NoError(t, err), validate manageEdges length, source IDs, and
destination IDs on the test goroutine, and ensure the callback returns the
FetchRelationships error to the transaction machinery.

In `@packages/go/analysis/hybrid/hybrid_integration_test.go`:
- Around line 694-699: Group the declarations of testContext, syncHarness,
container, unrelatedDomainService, and unrelatedDomain into a single var (...)
block at the top of TestGetManageEntraDSSyncEdgeComposition, preserving their
existing types and initialization behavior.

In `@packages/go/analysis/hybrid/hybrid.go`:
- Line 362: Introduce an entraDSSyncEdgeMaps struct containing the four
map[graph.ID][]graph.ID fields, update addManageEntraDSSyncEdges to accept this
struct instead of four separate map parameters, and adjust the PostHybrid call
site and function references to use the corresponding named fields.
- Around line 650-655: Update the relationship query used to populate
memberAddEdges so it filters end-node IDs using the eligible IDs already held by
azGroupToADGroups, alongside the existing azure.AddMembers and azure.Owns kind
filter. Use the driver's supported InIDs predicate and confirm it handles the
expected list size before applying the change; preserve the existing error
handling.

In `@packages/go/ein/azure_domain_service_test.go`:
- Around line 35-37: In TestConvertAzureDomainServiceToNode, group the
ingestTime and data initializations in a single var block at the top of the
function; likewise, group resourceID and principalID in a var block at the
second affected initialization site in
packages/go/ein/azure_domain_service_test.go (lines 135-138).

In
`@packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx`:
- Around line 20-50: Rename the component in LinuxAbuse.tsx from Abuse to
LinuxAbuse and update its default export accordingly. Remove the duplicated
component content by reusing the shared or consolidated implementation used by
WindowsAbuse, while preserving the Linux-specific entry point and rendered
behavior.

In
`@packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx`:
- Around line 20-50: Rename the component declaration and default export in
WindowsAbuse.tsx from Abuse to WindowsAbuse, keeping the existing
Windows-specific guidance unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 953f6764-0e69-4d52-b00c-f817ff34f49e

📥 Commits

Reviewing files that changed from the base of the PR and between ad0b7e3 and dfd59d0.

📒 Files selected for processing (80)
  • .gitignore
  • cmd/api/src/api/v2/azure.go
  • cmd/api/src/api/v2/azure_test.go
  • cmd/api/src/api/v2/edge.go
  • cmd/api/src/database/migration/extensions/az_graph_schema.sql
  • cmd/api/src/services/graphify/azure_convertors.go
  • cmd/ui/src/ducks/graph/graphutils.ts
  • cmd/ui/src/ducks/graph/types.ts
  • packages/csharp/graphschema/PropertyNames.cs
  • packages/cue/bh/ad/ad.cue
  • packages/cue/bh/azure/azure.cue
  • packages/cue/bh/bh.cue
  • packages/go/analysis/azure/azure_integration_test.go
  • packages/go/analysis/azure/domain_service.go
  • packages/go/analysis/azure/entra_domain_services.go
  • packages/go/analysis/azure/model.go
  • packages/go/analysis/azure/post.go
  • packages/go/analysis/edgecomposition/edgecomposition.go
  • packages/go/analysis/hybrid/composition.go
  • packages/go/analysis/hybrid/hybrid.go
  • packages/go/analysis/hybrid/hybrid_integration_test.go
  • packages/go/analysis/hybrid/hybrid_test.go
  • packages/go/analysis/post/post_integration_test.go
  • packages/go/ein/azure.go
  • packages/go/ein/azure_domain_service.go
  • packages/go/ein/azure_domain_service_test.go
  • packages/go/graphschema/ad/ad.go
  • packages/go/graphschema/azure/azure.go
  • packages/go/graphschema/azure/azure_test.go
  • packages/go/graphschema/common/common.go
  • packages/go/schemagen/generator/sql.go
  • packages/go/schemagen/generator/typescript.go
  • packages/go/schemagen/main.go
  • packages/go/schemagen/model/schema.go
  • packages/javascript/bh-shared-ui/src/commonSearches.test.ts
  • packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
  • packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContains/General.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/References.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/AZEntraDSContributor.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Abuse.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Opsec.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/References.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZOwner/General.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/AddEntraDSGroupMember.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Opsec.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.test.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/References.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/SyncedToEntraDSGroup.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/Opsec.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/References.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/SyncedToEntraDSUser.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx
  • packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx
  • packages/javascript/bh-shared-ui/src/graphSchema.ts
  • packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.test.tsx
  • packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.tsx
  • packages/javascript/bh-shared-ui/src/utils/content.ts
  • packages/javascript/bh-shared-ui/src/utils/icons.ts
  • packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoContent.test.tsx
  • packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
  • schemas/valid_edges.json
💤 Files with no reviewable changes (1)
  • packages/go/schemagen/generator/typescript.go

Comment on lines +1192 to +1195
SyncedToEntraDSUser,
AddEntraDSGroupMember,
ManageEntraDSSync,
ManageEntraDSSyncFilter,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Incomplete propagation of new EntraDS kinds into the derived relationship lists in packages/cue/bh/azure/azure.cue. Every new EntraDS relationship kind is registered in RelationshipKinds and PostProcessedRelationships, but three kinds are not carried into the derived membership lists that drive object-control counts and the Explore inbound/outbound panels. One incomplete registration pass explains all three gaps.

  • packages/cue/bh/azure/azure.cue#L1192-L1195: add SyncedToEntraDSGroup and EntraDSFor to InboundOutboundRelationshipKinds, or add a comment that records why they are excluded while SyncedToEntraDSUser is included.
  • packages/cue/bh/azure/azure.cue#L1034-L1035: add EntraDSContributor to ControlRelationshipKinds and InboundOutboundRelationshipKinds next to Contributor, or confirm the exclusion is intended. packages/go/analysis/azure/entra_domain_services.go treats EntraDSContributor and Contributor as equivalent control edges at Lines 96 and 327.

Regenerate packages/go/graphschema/azure/azure.go and packages/javascript/bh-shared-ui/src/graphSchema.ts after any change.

📍 Affects 1 file
  • packages/cue/bh/azure/azure.cue#L1192-L1195 (this comment)
  • packages/cue/bh/azure/azure.cue#L1034-L1035
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cue/bh/azure/azure.cue` around lines 1192 - 1195, The derived
relationship lists in packages/cue/bh/azure/azure.cue are missing EntraDS kinds:
at lines 1192-1195, add SyncedToEntraDSGroup and EntraDSFor to
InboundOutboundRelationshipKinds; at lines 1034-1035, add EntraDSContributor
alongside Contributor in both ControlRelationshipKinds and
InboundOutboundRelationshipKinds. Regenerate
packages/go/graphschema/azure/azure.go and
packages/javascript/bh-shared-ui/src/graphSchema.ts.

Comment on lines +26 to +40
func DomainServiceEntityDetails(ctx context.Context, db graph.Database, primaryDisplayKinds graphschema.PrimaryDisplayKinds, objectID string, hydrateCounts bool) (DomainServiceDetails, error) {
var details DomainServiceDetails

return details, db.ReadTransaction(ctx, func(tx graph.Transaction) error {
if node, err := FetchEntityByObjectID(tx, objectID); err != nil {
return err
} else {
details.Node = FromGraphNode(primaryDisplayKinds, node)
if hydrateCounts {
details, err = PopulateDomainServiceEntityDetailsCounts(tx, node, details)
}
return err
}
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Determine whether sibling *EntityDetails functions use the same return-operand pattern.
set -euo pipefail

echo "=== EntityDetails functions in the azure analysis package ==="
ast-grep outline packages/go/analysis/azure --items all --type function --match 'EntityDetails'

echo "=== 'return <var>, db.ReadTransaction(' occurrences repo-wide ==="
rg -nP -C2 'return\s+\w+,\s*db\.ReadTransaction\(' --type=go

Repository: SpecterOps/BloodHound

Length of output: 2809


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== DomainServiceEntityDetails and sibling implementations ==="
for file in packages/go/analysis/azure/{domain_service,service_principal,vm_scale_set,base,management_group,resource_group,application,managed_cluster,tenant,subscription,device,vm,container_registry,role,federated_identity_credentials,key_vault,user,web_app,automation_account,logic_app,group,function_app}.go; do
  if [ -f "$file" ]; then
    echo "--- $file"
    rg -n -A22 -B3 'func [A-Za-z0-9_]*EntityDetails\\(' "$file" || true
  fi
done

echo "=== Transaction-return patterns in the package ==="
rg -n -P -C3 'return\\s+[^\\n]*,\\s*(?:[A-Za-z0-9_]+\\.)?ReadTransaction\\(' packages/go/analysis/azure --type=go || true

echo "=== Go tool availability ==="
go version 2>/dev/null || true

Repository: SpecterOps/BloodHound

Length of output: 3494


🌐 Web query:

Go language specification order of evaluation return statement multiple result expressions function call

💡 Result:

In the Go programming language, the order of evaluation for expressions in a return statement (as well as in assignments and function calls) is governed by specific rules defined in the Go Language Specification [1][2]. When evaluating the operands of a return statement containing multiple result expressions, the following behavior applies: 1. Function calls, method calls, and channel communication operations are evaluated in strict lexical left-to-right order [3][4][2]. 2. The evaluation order of other operands (such as variables, constants, or arithmetic expressions that do not contain calls or communication) relative to these function/method/communication operations is not specified [3][4][5][6]. Because the spec does not define a total ordering for all elements, a return statement such as return a, f() (where a is a variable and f() is a function call) does not guarantee whether a is evaluated before or after f() is called [3][4][6]. If f() modifies a, the resulting return value is implementation-dependent and should not be relied upon [7][4][8]. These rules are consistent across assignments, return statements, and argument lists in function calls to allow compilers flexibility in optimization, such as managing register pressure and instruction scheduling [3][4][6]. Because this behavior is intentionally left partially unspecified, it is generally considered best practice to avoid code that depends on the relative evaluation order of mixed side-effect-producing and non-side-effect-producing expressions [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== EntityDetails implementations ==="
rg -n -U -P -A22 -B3 'func [A-Za-z0-9_]+EntityDetails\([^)]*\)[^{]*\{' packages/go/analysis/azure --type=go || true

echo "=== ReadTransaction return expressions ==="
rg -n -F -C3 'ReadTransaction(ctx' packages/go/analysis/azure --type=go || true

echo "=== Exact return-call patterns ==="
rg -n -F -C2 'return details, db.ReadTransaction(' packages/go/analysis/azure --type=go || true
rg -n -F -C2 'return details, database.ReadTransaction(' packages/go/analysis/azure --type=go || true

echo "=== Local Go specification wording ==="
for spec in /usr/local/go/doc/go_spec.html /usr/share/doc/golang-go/go_spec.html; do
  if [ -f "$spec" ]; then
    echo "--- $spec"
    rg -n -A10 -B5 'order of evaluation|return statement' "$spec" | head -120
  fi
done

Repository: SpecterOps/BloodHound

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== EntityDetails return-pattern counts ==="
printf 'same pattern: '
rg -l -F 'return details, db.ReadTransaction(' packages/go/analysis/azure --type=go | wc -l
printf 'explicit txErr pattern: '
rg -l -F 'txErr := db.ReadTransaction(' packages/go/analysis/azure --type=go | wc -l

echo "=== Local Go specification evaluation-order section ==="
spec=/usr/local/go/doc/go_spec.html
if [ -f "$spec" ]; then
  rg -n -i -A8 -B8 'order of evaluation' "$spec" | head -80
else
  echo "local specification not found"
fi

echo "=== Standalone compiler behavior for a captured return operand ==="
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
cat >"$tmpdir/main.go" <<'EOF'
package main

import "fmt"

var value int

func mutate() error {
	value = 42
	return nil
}

func mixed() (int, error) {
	value = 0
	return value, mutate()
}

func explicit() (int, error) {
	value = 0
	err := mutate()
	return value, err
}

func main() {
	mixedValue, mixedErr := mixed()
	explicitValue, explicitErr := explicit()
	fmt.Printf("mixed=%d,%v explicit=%d,%v\n", mixedValue, mixedErr, explicitValue, explicitErr)
}
EOF
(cd "$tmpdir" && go run main.go)

Repository: SpecterOps/BloodHound

Length of output: 246


🏁 Script executed:

#!/bin/bash
set -u

echo "=== Local Go specification evaluation-order section ==="
spec=/usr/local/go/doc/go_spec.html
if [ -f "$spec" ]; then
  rg -n -i -A8 -B8 'order of evaluation' "$spec" | head -80 || true
else
  echo "local specification not found"
fi

echo "=== Standalone compiler behavior for a captured return operand ==="
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
cat >"$tmpdir/main.go" <<'EOF'
package main

import "fmt"

var value int

func mutate() error {
	value = 42
	return nil
}

func mixed() (int, error) {
	value = 0
	return value, mutate()
}

func explicit() (int, error) {
	value = 0
	err := mutate()
	return value, err
}

func main() {
	mixedValue, mixedErr := mixed()
	explicitValue, explicitErr := explicit()
	fmt.Printf("mixed=%d,%v explicit=%d,%v\n", mixedValue, mixedErr, explicitValue, explicitErr)
}
EOF
(cd "$tmpdir" && go run main.go)

Repository: SpecterOps/BloodHound

Length of output: 1104


🏁 Script executed:

#!/bin/bash
set -euo pipefail

spec=/usr/local/go/doc/go_spec.html
if [ -f "$spec" ]; then
  sed -n '5836,5875p' "$spec"
else
  echo "local specification not found"
fi

Repository: SpecterOps/BloodHound

Length of output: 1840


Make return evaluation explicit in all Azure *EntityDetails functions. Go leaves the order between details and db.ReadTransaction unspecified. Update the 22 matching functions consistently instead of changing only this function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/azure/domain_service.go` around lines 26 - 40, The Azure
*EntityDetails functions currently rely on unspecified return evaluation order
when returning details alongside db.ReadTransaction. Update all 22 matching
EntityDetails functions, including DomainServiceEntityDetails, to explicitly
execute the transaction, capture its error, and return the populated details
with that error afterward.

Comment on lines +144 to +158
containmentPaths, err := ops.TraversePaths(tx, ops.TraversalPlan{
Root: domain,
Direction: graph.DirectionOutbound,
BranchQuery: func() graph.Criteria {
return query.Kind(query.Relationship(), graphschemaAD.Contains)
},
PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
return segment.Node.ID == targetGroup.ID
},
})
if err != nil {
return err
} else if containmentPaths.Len() == 0 {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle graph.ErrNoResultsFound from the containment traversal.

filterContainedDomainUsers in packages/go/analysis/hybrid/hybrid.go (lines 571-579) documents that PostgreSQL reports an empty traversal as graph.ErrNoResultsFound, while other drivers return an empty PathSet. This traversal uses the same plan but treats every error as fatal. On PostgreSQL, a domain with no matching containment path makes GetManageEntraDSSyncEdgeComposition return an error. The API then responds with HTTP 500 instead of an empty composition.

Treat the empty-traversal error as "no containment evidence" and continue to the next correlation path.

🐛 Proposed fix
 			containmentPaths, err := ops.TraversePaths(tx, ops.TraversalPlan{
 				Root:      domain,
 				Direction: graph.DirectionOutbound,
 				BranchQuery: func() graph.Criteria {
 					return query.Kind(query.Relationship(), graphschemaAD.Contains)
 				},
 				PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
 					return segment.Node.ID == targetGroup.ID
 				},
 			})
-			if err != nil {
+			if errors.Is(err, graph.ErrNoResultsFound) {
+				continue
+			} else if err != nil {
 				return err
 			} else if containmentPaths.Len() == 0 {
 				continue
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
containmentPaths, err := ops.TraversePaths(tx, ops.TraversalPlan{
Root: domain,
Direction: graph.DirectionOutbound,
BranchQuery: func() graph.Criteria {
return query.Kind(query.Relationship(), graphschemaAD.Contains)
},
PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
return segment.Node.ID == targetGroup.ID
},
})
if err != nil {
return err
} else if containmentPaths.Len() == 0 {
continue
}
containmentPaths, err := ops.TraversePaths(tx, ops.TraversalPlan{
Root: domain,
Direction: graph.DirectionOutbound,
BranchQuery: func() graph.Criteria {
return query.Kind(query.Relationship(), graphschemaAD.Contains)
},
PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
return segment.Node.ID == targetGroup.ID
},
})
if errors.Is(err, graph.ErrNoResultsFound) {
continue
} else if err != nil {
return err
} else if containmentPaths.Len() == 0 {
continue
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/hybrid/composition.go` around lines 144 - 158, Update
the containment traversal error handling in GetManageEntraDSSyncEdgeComposition
so graph.ErrNoResultsFound is treated like an empty containmentPaths result:
continue to the next correlation path. Preserve returning other traversal errors
unchanged.

Comment on lines +449 to +459
db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
edges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
return query.Kind(query.Relationship(), azure.AddEntraDSGroupMember)
}))
assert.Nil(t, err)
assert.Len(t, edges, 1)
edge = edges[0]
return nil
})

composition, err := GetAddEntraDSGroupMemberEdgeComposition(context.Background(), db, edge)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use require.Len before indexing edges.

Line 454 uses assert.Len. assert records the failure and continues. If the query returns no relationships, line 455 indexes edges[0] and the test panics with an index-out-of-range error. edge also stays nil and line 459 passes nil into GetAddEntraDSGroupMemberEdgeComposition. The same block at lines 733-734 already uses require. Apply the same approach here.

🐛 Proposed fix
 			var edge *graph.Relationship
 			db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
 				edges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
 					return query.Kind(query.Relationship(), azure.AddEntraDSGroupMember)
 				}))
-				assert.Nil(t, err)
-				assert.Len(t, edges, 1)
+				require.NoError(t, err)
+				require.Len(t, edges, 1)
 				edge = edges[0]
 				return nil
 			})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
edges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
return query.Kind(query.Relationship(), azure.AddEntraDSGroupMember)
}))
assert.Nil(t, err)
assert.Len(t, edges, 1)
edge = edges[0]
return nil
})
composition, err := GetAddEntraDSGroupMemberEdgeComposition(context.Background(), db, edge)
db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
edges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
return query.Kind(query.Relationship(), azure.AddEntraDSGroupMember)
}))
require.NoError(t, err)
require.Len(t, edges, 1)
edge = edges[0]
return nil
})
composition, err := GetAddEntraDSGroupMemberEdgeComposition(context.Background(), db, edge)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/hybrid/hybrid_integration_test.go` around lines 449 -
459, Update the relationship-fetch block around FetchRelationships to use
require.Len for the edges count assertion before indexing edges[0]. Keep the
existing assertion and edge assignment behavior otherwise unchanged, matching
the require-based block later in the test.

// (AZAddMembers / AZOwns) between them. When syncUser/syncGroup are true, matching on-prem AD User/Group nodes are
// created (via ad.AADObjectID) so the corresponding SyncedToEntraDS edges are produced by PostHybrid. Pass an empty
// kind as controlKind to omit the control edge entirely. Returns the AZUser, on-prem User, AZGroup, on-prem Group.
func setupEntraDSGroupMemberHarness(t *testing.T, testContext *integration.GraphTestContext, controlKind graph.Kind, syncUser, syncGroup bool) (azUser, adUser, azGroup, adGroup *graph.Node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove the named return parameters.

setupEntraDSGroupMemberHarness declares (azUser, adUser, azGroup, adGroup *graph.Node) as named results. Declare the variables inside the function body instead and keep the return type list unnamed.

♻️ Proposed refactor
-func setupEntraDSGroupMemberHarness(t *testing.T, testContext *integration.GraphTestContext, controlKind graph.Kind, syncUser, syncGroup bool) (azUser, adUser, azGroup, adGroup *graph.Node) {
+func setupEntraDSGroupMemberHarness(t *testing.T, testContext *integration.GraphTestContext, controlKind graph.Kind, syncUser, syncGroup bool) (*graph.Node, *graph.Node, *graph.Node, *graph.Node) {
 	t.Helper()
 
+	var (
+		adUser  *graph.Node
+		adGroup *graph.Node
+	)
+
 	tenantID := integration.RandomObjectID(t)
 	tenant := testContext.NewAzureTenant(tenantID)
 
 	azUserObjectID := integration.RandomObjectID(t)
 	azGroupObjectID := integration.RandomObjectID(t)
-	azUser = testContext.NewAzureUser("AZ User", "azuser@specter.dev", "", azUserObjectID, "", tenantID, false)
-	azGroup = testContext.NewAzureGroup("AZ Group", azGroupObjectID, tenantID)
+	azUser := testContext.NewAzureUser("AZ User", "azuser@specter.dev", "", azUserObjectID, "", tenantID, false)
+	azGroup := testContext.NewAzureGroup("AZ Group", azGroupObjectID, tenantID)

The documentation comment at lines 907-910 already names the returned values, so readability is preserved.

As per coding guidelines: "Do not use named return parameters in user- or agent-written Go code; define all return variables within the function."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/hybrid/hybrid_integration_test.go` at line 911, Update
setupEntraDSGroupMemberHarness to use an unnamed return type list, then declare
azUser, adUser, azGroup, and adGroup inside the function body while preserving
the existing return behavior and documentation.

Source: Coding guidelines

Comment on lines +343 to +355
func addEntraDSAdminGroupTenant(entraDSAdminGroupTenantMap map[graph.ID]string, group *graph.Node) error {
if name, err := group.Properties.Get(common.Name.String()).String(); err != nil {
return err
} else if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(name)), entraDSAdminGroupNamePrefix) {
return nil
} else if tenantID, err := group.Properties.Get(azure.TenantID.String()).String(); err != nil {
return err
} else if normalizedTenantID := normalizeObjectID(tenantID); len(normalizedTenantID) != 0 {
entraDSAdminGroupTenantMap[group.ID] = normalizedTenantID
}

return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Treat a missing name or tenantid property as "not an Entra DS admin group".

addEntraDSAdminGroupTenant returns the raw error from Properties.Get(...).String(). graph.ErrPropertyNotFound therefore propagates out of the tenant loop and aborts the whole PostHybrid read transaction. A single Entra group without a name property then suppresses all hybrid relationships, including the pre-existing SyncedToEntraUser and SyncedToADUser edges.

The file already has normalizedNodeProperty for exactly this case. Use it here.

🛡️ Proposed fix
 func addEntraDSAdminGroupTenant(entraDSAdminGroupTenantMap map[graph.ID]string, group *graph.Node) error {
-	if name, err := group.Properties.Get(common.Name.String()).String(); err != nil {
-		return err
-	} else if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(name)), entraDSAdminGroupNamePrefix) {
-		return nil
-	} else if tenantID, err := group.Properties.Get(azure.TenantID.String()).String(); err != nil {
-		return err
-	} else if normalizedTenantID := normalizeObjectID(tenantID); len(normalizedTenantID) != 0 {
-		entraDSAdminGroupTenantMap[group.ID] = normalizedTenantID
-	}
-
-	return nil
+	name, hasName, err := normalizedNodeProperty(group, common.Name.String())
+	if err != nil {
+		return err
+	} else if !hasName || !strings.HasPrefix(name, entraDSAdminGroupNamePrefix) {
+		return nil
+	}
+
+	tenantID, hasTenantID, err := normalizedNodeProperty(group, azure.TenantID.String())
+	if err != nil {
+		return err
+	} else if hasTenantID {
+		entraDSAdminGroupTenantMap[group.ID] = tenantID
+	}
+
+	return nil
 }

normalizedNodeProperty already uppercases and trims, so the extra strings.ToUpper(strings.TrimSpace(...)) is no longer needed.

addNodeToObjectIDMap at lines 321-329 has the same pattern for objectid. Consider applying the same treatment there.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func addEntraDSAdminGroupTenant(entraDSAdminGroupTenantMap map[graph.ID]string, group *graph.Node) error {
if name, err := group.Properties.Get(common.Name.String()).String(); err != nil {
return err
} else if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(name)), entraDSAdminGroupNamePrefix) {
return nil
} else if tenantID, err := group.Properties.Get(azure.TenantID.String()).String(); err != nil {
return err
} else if normalizedTenantID := normalizeObjectID(tenantID); len(normalizedTenantID) != 0 {
entraDSAdminGroupTenantMap[group.ID] = normalizedTenantID
}
return nil
}
func addEntraDSAdminGroupTenant(entraDSAdminGroupTenantMap map[graph.ID]string, group *graph.Node) error {
name, hasName, err := normalizedNodeProperty(group, common.Name.String())
if err != nil {
return err
} else if !hasName || !strings.HasPrefix(name, entraDSAdminGroupNamePrefix) {
return nil
}
tenantID, hasTenantID, err := normalizedNodeProperty(group, azure.TenantID.String())
if err != nil {
return err
} else if hasTenantID {
entraDSAdminGroupTenantMap[group.ID] = tenantID
}
return nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/go/analysis/hybrid/hybrid.go` around lines 343 - 355, Update
addEntraDSAdminGroupTenant to retrieve both name and tenantID through
normalizedNodeProperty, treating missing properties as absent rather than
returning graph.ErrPropertyNotFound; preserve prefix matching and normalized
tenant mapping. Remove the now-redundant uppercase/trim operations because
normalizedNodeProperty handles them. Apply the same missing-property handling to
addNodeToObjectIDMap for objectid.

Comment on lines +486 to +488
description:
'Shows principals that can manage Microsoft Entra Domain Services (Entra DS) synchronization, identified by the ManageEntraDSSync edge, and security settings including NTLM, Kerberos, TLS, LDAP signing, channel binding, and Secure LDAP configuration and certificates.',
query: `MATCH p = (principal:AZBase)-[:AZManageEntraDS]->(domainService:AZEntraDS)\nRETURN p\nLIMIT 1000`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the description with the queried edge.

The description states the search identifies principals by the ManageEntraDSSync edge. The query matches AZManageEntraDS instead. Update the description so it names the edge the query uses.

📝 Proposed description fix
-                description:
-                    'Shows principals that can manage Microsoft Entra Domain Services (Entra DS) synchronization, identified by the ManageEntraDSSync edge, and security settings including NTLM, Kerberos, TLS, LDAP signing, channel binding, and Secure LDAP configuration and certificates.',
+                description:
+                    'Shows principals that can manage Microsoft Entra Domain Services (Entra DS), identified by the AZManageEntraDS edge, including synchronization and security settings such as NTLM, Kerberos, TLS, LDAP signing, channel binding, and Secure LDAP configuration and certificates.',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
description:
'Shows principals that can manage Microsoft Entra Domain Services (Entra DS) synchronization, identified by the ManageEntraDSSync edge, and security settings including NTLM, Kerberos, TLS, LDAP signing, channel binding, and Secure LDAP configuration and certificates.',
query: `MATCH p = (principal:AZBase)-[:AZManageEntraDS]->(domainService:AZEntraDS)\nRETURN p\nLIMIT 1000`,
description:
'Shows principals that can manage Microsoft Entra Domain Services (Entra DS), identified by the AZManageEntraDS edge, including synchronization and security settings such as NTLM, Kerberos, TLS, LDAP signing, channel binding, and Secure LDAP configuration and certificates.',
query: `MATCH p = (principal:AZBase)-[:AZManageEntraDS]->(domainService:AZEntraDS)\nRETURN p\nLIMIT 1000`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts` around lines 486 -
488, Update the description associated with the query matching the
AZManageEntraDS edge so it names AZManageEntraDS instead of ManageEntraDSSync,
while preserving the remaining description text.

Comment on lines +1398 to +1401
AzureRelationshipKind.SyncedToEntraDSUser,
AzureRelationshipKind.AddEntraDSGroupMember,
AzureRelationshipKind.ManageEntraDSSync,
AzureRelationshipKind.ManageEntraDSSyncFilter,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add SyncedToEntraDSGroup to Azure pathfinding.

SyncedToEntraDSGroup is declared but is absent from AzurePathfindingEdges. An AddEntraDSGroupMember path can reach an Entra ID group but cannot traverse the synchronization edge into the Entra DS group. This prevents synchronized-group control paths from being found.

Proposed fix
         AzureRelationshipKind.SyncedToEntraUser,
+        AzureRelationshipKind.SyncedToEntraDSGroup,
         AzureRelationshipKind.AddEntraDSGroupMember,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/javascript/bh-shared-ui/src/graphSchema.ts` around lines 1398 -
1401, Add AzureRelationshipKind.SyncedToEntraDSGroup to the
AzurePathfindingEdges collection alongside the existing Entra DS relationship
kinds, preserving all current entries.

Comment on lines +227 to +233
edgeTypes: [
AzureRelationshipKind.SyncedToEntraUser,
AzureRelationshipKind.SyncedToEntraDSUser,
AzureRelationshipKind.AddEntraDSGroupMember,
AzureRelationshipKind.ManageEntraDSSync,
AzureRelationshipKind.ManageEntraDSSyncFilter,
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add SyncedToEntraDSGroup to the Cross Platform category.

schemas/valid_edges.json defines SyncedToEntraDSGroup as an Azure-to-AD post-processing relationship. This category omits it, so users cannot select that new cross-platform relationship through the edge filter. Add AzureRelationshipKind.SyncedToEntraDSGroup here.

Proposed fix
                 edgeTypes: [
                     AzureRelationshipKind.SyncedToEntraUser,
                     AzureRelationshipKind.SyncedToEntraDSUser,
+                    AzureRelationshipKind.SyncedToEntraDSGroup,
                     AzureRelationshipKind.AddEntraDSGroupMember,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
edgeTypes: [
AzureRelationshipKind.SyncedToEntraUser,
AzureRelationshipKind.SyncedToEntraDSUser,
AzureRelationshipKind.AddEntraDSGroupMember,
AzureRelationshipKind.ManageEntraDSSync,
AzureRelationshipKind.ManageEntraDSSyncFilter,
],
edgeTypes: [
AzureRelationshipKind.SyncedToEntraUser,
AzureRelationshipKind.SyncedToEntraDSUser,
AzureRelationshipKind.SyncedToEntraDSGroup,
AzureRelationshipKind.AddEntraDSGroupMember,
AzureRelationshipKind.ManageEntraDSSync,
AzureRelationshipKind.ManageEntraDSSyncFilter,
],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx`
around lines 227 - 233, Update the Cross Platform category’s edgeTypes list to
include AzureRelationshipKind.SyncedToEntraDSGroup alongside the existing Azure
relationship kinds, preserving all current entries.


PostProcessedRelationships: [
ExecuteCommand,
ManageEntraDS,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not be adding additional edges to the post-processed list. Any new edges implemented using Post-Processing must utilize the Delta-Change Apply strategy. Please see https://github.com/SpecterOps/BloodHound/pull/2693/changes for an example of how this is done, and feel free to reach out to me for assistance.

@StephenHinck StephenHinck left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other requests from ENG aside, adding a block to this PR as it was not implemented on the newer DCA strategy for post-processing. Any additions to post-processing utilize this path for performance reasons.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api A pull request containing changes affecting the API code. documentation Improvements or additions to documentation enhancement New feature or request go Pull requests that update go code javascript Pull requests that update javascript code user interface A pull request containing changes affecting the UI code.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants