- AZOwner targets resources in AzureRM (for example {AZResourceGroupLink}, {AZSubscriptionLink} and {AZVMLink}
- ) through role assignment called “Owner”.
+ AZOwner targets resources in AzureRM (for example {AZResourceGroupLink}, {AZSubscriptionLink}, {AZVMLink},
+ and {AZDomainServiceLink}) through a role assignment called “Owner”.
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx
index 4bd4cb416ea5..2df6ebe73b2e 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx
@@ -21,9 +21,9 @@ const Abuse: FC = () => {
return (
<>
- This role can be used to grant yourself or another principal any privilege you want against Automation
- Accounts, VMs, Key Vaults, and Resource Groups. For example, you can make yourself an administrator of
- an Azure Subscription by assigning the Owner role at the Subscription scope.
+ This role can be used to grant yourself or another principal an abusable role against Automation
+ Accounts, VMs, Key Vaults, Resource Groups, and Microsoft Entra Domain Services resources. For example,
+ you can assign yourself the Owner or Contributor role at the target resource scope.
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/AddEntraDSGroupMember.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/AddEntraDSGroupMember.tsx
new file mode 100644
index 000000000000..15edee2a1684
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/AddEntraDSGroupMember.tsx
@@ -0,0 +1,33 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import Composition from './Composition';
+import General from './General';
+import LinuxAbuse from './LinuxAbuse';
+import Opsec from './Opsec';
+import References from './References';
+import WindowsAbuse from './WindowsAbuse';
+
+const AddEntraDSGroupMember = {
+ general: General,
+ windowsAbuse: WindowsAbuse,
+ linuxAbuse: LinuxAbuse,
+ opsec: Opsec,
+ references: References,
+ composition: Composition,
+};
+
+export default AddEntraDSGroupMember;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx
new file mode 100644
index 000000000000..43ff64adfb88
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx
@@ -0,0 +1,82 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import { useExploreGraph } from '../../../hooks/useExploreGraph/useExploreGraph';
+import { render, screen, waitFor } from '../../../test-utils';
+import Composition from './Composition';
+
+const server = setupServer(
+ rest.get('/api/v2/relationships/:relationshipId', (req, res, ctx) => {
+ return res(
+ ctx.json({
+ data: {
+ relationship_id: Number(req.params.relationshipId),
+ kind: { relationship_kind_id: 1, name: 'AddEntraDSGroupMember' },
+ source_node_id: 1,
+ target_node_id: 2,
+ properties: {},
+ },
+ })
+ );
+ }),
+ rest.get('/api/v2/graphs/edge-composition', (_req, res, ctx) => {
+ return res(
+ ctx.json({
+ data: { nodes: {}, edges: [] },
+ })
+ );
+ }),
+ rest.get('/api/v2/config', (_req, res, ctx) => {
+ return res(
+ ctx.json({
+ data: [],
+ })
+ );
+ })
+);
+
+beforeAll(() => server.listen());
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const CompositionGraphProbe = () => {
+ const { data } = useExploreGraph();
+
+ return data ? graph-loaded : null;
+};
+
+describe('AddEntraDSGroupMember Composition', () => {
+ it('uses the tuple-form relationship key required by the composition graph query', async () => {
+ render(
+ <>
+
+
+ >,
+ {
+ route: '/?searchType=composition&relationshipQueryItemId=rel_99',
+ }
+ );
+
+ await waitFor(() => {
+ expect(window.location.search).toContain(
+ 'relationshipQueryItemId=rel_1_AddEntraDSGroupMember_2'
+ );
+ });
+ expect(await screen.findByText('graph-loaded')).toBeInTheDocument();
+ });
+});
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx
new file mode 100644
index 000000000000..443f892b7075
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx
@@ -0,0 +1,71 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Alert, Box, Skeleton } from '@mui/material';
+import { Typography } from 'doodle-ui';
+import { FC, useEffect } from 'react';
+import { EdgeInfoProps } from '..';
+import { EdgeInfoItems, useEdgeInfoItems } from '../../../hooks/useExploreGraph/useEdgeInfoItems';
+import { useExploreParams } from '../../../hooks/useExploreParams';
+import { createRelItemId } from '../../../utils';
+import VirtualizedNodeList from '../../VirtualizedNodeList';
+
+const Composition: FC = ({ sourceDBId, targetDBId, edgeName }) => {
+ const { relationshipQueryItemId, searchType, setExploreParams } = useExploreParams();
+ const { isLoading, isError, nodesArray } = useEdgeInfoItems({
+ sourceDBId,
+ targetDBId,
+ edgeName,
+ type: EdgeInfoItems['composition'],
+ });
+
+ useEffect(() => {
+ if (searchType !== 'composition' || sourceDBId === undefined || targetDBId === undefined || !edgeName) {
+ return;
+ }
+
+ // The composition graph query still consumes the tuple-form relationship key.
+ const compositionRelationshipQueryItemId = createRelItemId(
+ sourceDBId.toString(),
+ edgeName,
+ targetDBId.toString()
+ );
+
+ if (relationshipQueryItemId !== compositionRelationshipQueryItemId) {
+ setExploreParams({ relationshipQueryItemId: compositionRelationshipQueryItemId }, { replace: true });
+ }
+ }, [edgeName, relationshipQueryItemId, searchType, setExploreParams, sourceDBId, targetDBId]);
+
+ return (
+ <>
+
+ The relationship represents the effective outcome of the configuration and relationships between several
+ different objects. All objects involved in the creation of this relationship are listed here:
+
+
+ {isLoading ? (
+
+ ) : isError ? (
+ Couldn't load edge composition
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export default Composition;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
new file mode 100644
index 000000000000..aa7b60485e06
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
@@ -0,0 +1,46 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const General: FC = () => {
+ return (
+ <>
+
+ This relationship indicates that a synchronized Entra user can effectively add members to an Entra
+ Domain Services group by controlling the corresponding synchronized Entra group.
+
+
+ The relationship is composed from three conditions: the Entra user is synchronized to Entra Domain
+ Services; the Entra user owns or can add members to an Entra group; and the Entra group is synchronized
+ to an Entra Domain Services group.
+
+
+ Because the Entra user already has a usable Entra Domain Services identity, they can add themselves or
+ another controlled synchronized principal to the Entra group and wait for membership to synchronize into
+ the Entra Domain Services group. This effectively grants the Entra user any privileges held by the Entra
+ Domain Services group.
+
+
+ Only direct membership in the source Entra group is synchronized. Nested Entra groups do not satisfy
+ this relationship.
+
+ >
+ );
+};
+
+export default General;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx
new file mode 100644
index 000000000000..d347f8fc061e
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx
@@ -0,0 +1,41 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const Abuse: FC = () => {
+ return (
+ <>
+
+ Using the Entra user's control over the Entra group, add the Entra user or another controlled
+ synchronized principal as a direct member of the Entra group. Using the Microsoft Graph API, for example
+ with a POST to the group's members reference:
+
+
+ {
+ 'curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" \\\n https://graph.microsoft.com/v1.0/groups//members/\\$ref \\\n -d \'{"@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/"}\''
+ }
+
+
+ After Entra Domain Services synchronizes the direct membership change, the principal becomes a member of
+ the corresponding Entra Domain Services group and inherits its access within the managed domain.
+
+ >
+ );
+};
+
+export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Opsec.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Opsec.tsx
new file mode 100644
index 000000000000..7da766c2cbae
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Opsec.tsx
@@ -0,0 +1,30 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const Opsec: FC = () => {
+ return (
+
+ Modifying Entra group membership generates directory audit logs in Microsoft Entra ID, and the resulting
+ change is replicated into Microsoft Entra Domain Services, which may produce Windows Logon Events and
+ entries in log solutions when Azure Monitor Diagnostic settings are enabled for Entra Domain Services.
+
+ );
+};
+
+export default Opsec;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx
new file mode 100644
index 000000000000..d4ca8e2bbf4d
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx
@@ -0,0 +1,47 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Box, Link } from '@mui/material';
+import React, { FC } from 'react';
+
+const References: FC = () => {
+ const references = [
+ {
+ label: 'How objects and credentials are synchronized in a Microsoft Entra Domain Services managed domain',
+ link: 'https://learn.microsoft.com/en-us/entra/identity/domain-services/synchronization',
+ },
+ {
+ label: 'Add members to a group using Microsoft Graph',
+ link: 'https://learn.microsoft.com/en-us/graph/api/group-post-members',
+ },
+ ];
+ return (
+
+ {references.map((reference) => {
+ return (
+
+
+ {reference.label}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default References;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx
new file mode 100644
index 000000000000..c221f96f366a
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx
@@ -0,0 +1,41 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const Abuse: FC = () => {
+ return (
+ <>
+
+ Using the Entra user's control over the Entra group, add the Entra user or another controlled
+ synchronized principal as a direct member of the Entra group. In Microsoft Graph PowerShell this can be
+ done with:
+
+
+ {
+ 'New-MgGroupMemberByRef -GroupId "" -OdataId "https://graph.microsoft.com/v1.0/directoryObjects/"'
+ }
+
+
+ After Entra Domain Services synchronizes the direct membership change, the principal becomes a member of
+ the corresponding Entra Domain Services group and inherits its access within the managed domain.
+
+ >
+ );
+};
+
+export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/General.tsx
new file mode 100644
index 000000000000..9bb887eca896
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/General.tsx
@@ -0,0 +1,54 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const General: FC = () => {
+ return (
+ <>
+
+ This relationship indicates that control of either a Microsoft Entra Domain Services managed domain or,
+ in the filtered-sync case described below, the Domain Controller Services service principal can be used
+ to create usable Entra Domain Services users with baseline Domain Users access.
+
+
+ BloodHound emits this relationship from an AZDomainService because the managed domain controls the broad
+ synchronization boundary through its filtered sync and sync scope settings. A principal that controls
+ the resource can change those settings so an attacker-controlled identity is eligible for
+ synchronization.
+
+
+ BloodHound can also emit this relationship from the Domain Controller Services service principal with
+ application ID 2565bd9d-da50-47d4-8b85-4c97f669dc36, but only when the related managed domain has
+ filtered sync set to Enabled and sync scope set to All. In that state, a principal that controls the
+ service principal can assign an attacker-controlled Entra security group to the filtered synchronization
+ scope. The direct members of that group are then materialized as Entra Domain Services users.
+
+
+ Every newly materialized Entra Domain Services user receives Domain Users as its primary group. In
+ BloodHound, Domain Users is already nested into Authenticated Users, which is in turn nested into
+ Everyone, so this relationship represents baseline authenticated access to the managed domain.
+
+
+ Direct user assignments may appear in the portal, but they are not honored by the Entra Domain Services
+ sync engine. Only explicitly scoped groups and their direct members are synchronized.
+
+ >
+ );
+};
+
+export default General;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx
new file mode 100644
index 000000000000..03e72584f367
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx
@@ -0,0 +1,38 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const Abuse: FC = () => {
+ return (
+ <>
+
+ From an AZDomainService source, change the managed domain synchronization settings so an
+ attacker-controlled identity is eligible for synchronization, then wait for synchronization.
+
+
+ From a Domain Controller Services service principal source, assign an attacker-controlled Entra security
+ group to the filtered synchronization scope, add an attacker-controlled Entra user as a direct member of
+ that group, and wait for synchronization. The user is then materialized in Entra Domain Services with
+ Domain Users access. A cloud-only user may need an Entra password change before password material is
+ available for authentication.
+
+ >
+ );
+};
+
+export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/Opsec.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/Opsec.tsx
new file mode 100644
index 000000000000..42bdc86eddea
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/Opsec.tsx
@@ -0,0 +1,31 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const Opsec: FC = () => {
+ return (
+
+ ARM updates to the managed domain synchronization settings, app-role assignments on the Domain Controller
+ Services service principal, and Entra group membership changes generate Microsoft Entra audit activity.
+ Changing the scope triggers an Entra Domain Services resynchronization, and subsequent authentication may
+ generate Windows logon events and Azure Monitor diagnostic records when those logs are enabled.
+
+ );
+};
+
+export default Opsec;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/References.tsx
new file mode 100644
index 000000000000..dc900ee74769
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/References.tsx
@@ -0,0 +1,56 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Box, Link } from '@mui/material';
+import React, { FC } from 'react';
+
+const References: FC = () => {
+ const references = [
+ {
+ label: 'Microsoft.AAD/domainServices reference',
+ link: 'https://learn.microsoft.com/en-us/azure/templates/microsoft.aad/domainservices',
+ },
+ {
+ label: 'Domain Services Contributor built-in role',
+ link: 'https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/identity#domain-services-contributor',
+ },
+ {
+ label: 'How objects and credentials are synchronized in a Microsoft Entra Domain Services managed domain',
+ link: 'https://learn.microsoft.com/en-us/entra/identity/domain-services/synchronization',
+ },
+ {
+ label: 'Configure scoped synchronization for Microsoft Entra Domain Services',
+ link: 'https://learn.microsoft.com/en-us/entra/identity/domain-services/scoped-synchronization',
+ },
+ ];
+
+ return (
+
+ {references.map((reference) => {
+ return (
+
+
+ {reference.label}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default References;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/SyncEntraDSUsers.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/SyncEntraDSUsers.tsx
new file mode 100644
index 000000000000..0f3fa7371978
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/SyncEntraDSUsers.tsx
@@ -0,0 +1,31 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import General from './General';
+import LinuxAbuse from './LinuxAbuse';
+import Opsec from './Opsec';
+import References from './References';
+import WindowsAbuse from './WindowsAbuse';
+
+const SyncEntraDSUsers = {
+ general: General,
+ windowsAbuse: WindowsAbuse,
+ linuxAbuse: LinuxAbuse,
+ opsec: Opsec,
+ references: References,
+};
+
+export default SyncEntraDSUsers;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/WindowsAbuse.tsx
new file mode 100644
index 000000000000..03e72584f367
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/WindowsAbuse.tsx
@@ -0,0 +1,38 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const Abuse: FC = () => {
+ return (
+ <>
+
+ From an AZDomainService source, change the managed domain synchronization settings so an
+ attacker-controlled identity is eligible for synchronization, then wait for synchronization.
+
+
+ From a Domain Controller Services service principal source, assign an attacker-controlled Entra security
+ group to the filtered synchronization scope, add an attacker-controlled Entra user as a direct member of
+ that group, and wait for synchronization. The user is then materialized in Entra Domain Services with
+ Domain Users access. A cloud-only user may need an Entra password change before password material is
+ available for authentication.
+
+ >
+ );
+};
+
+export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
index 0ce4de312e16..aeefedf14be7 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
@@ -36,7 +36,9 @@ const General: FC = () => {
This relationship is informational. Control of the Entra group does not by itself provide a usable Entra
- Domain Services identity, so the relationship is excluded from pathfinding.
+ Domain Services identity. The related AddEntraDSGroupMember edge captures the case where a synchronized
+ Entra user can use control of a synchronized Entra group to gain effective membership in the Entra
+ Domain Services group.
>
);
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx
index 493a7a4c9f33..92eb30fb70fa 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx
@@ -36,6 +36,7 @@ import AZAvereContributor from './AZAvereContributor/AZAvereContributor';
import AZCloudAppAdmin from './AZCloudAppAdmin/AZCloudAppAdmin';
import AZContains from './AZContains/AZContains';
import AZContributor from './AZContributor/AZContributor';
+import AZDomainServicesContributor from './AZDomainServicesContributor/AZDomainServicesContributor';
import AZExecuteCommand from './AZExecuteCommand/AZExecuteCommand';
import AZGetCertificates from './AZGetCertificates/AZGetCertificates';
import AZGetKeys from './AZGetKeys/AZGetKeys';
@@ -73,6 +74,7 @@ import AZVMContributor from './AZVMContributor/AZVMContributor';
import AZWebsiteContributor from './AZWebsiteContributor/AZWebsiteContributor';
import AbuseTGTDelegation from './AbuseTGTDelegation/AbuseTGTDelegation';
import AddAllowedToAct from './AddAllowedToAct/AddAllowedToAct';
+import AddEntraDSGroupMember from './AddEntraDSGroupMember/AddEntraDSGroupMember';
import AddKeyCredentialLink from './AddKeyCredentialLink/AddKeyCredentialLink';
import AddMember from './AddMember/AddMember';
import AddSelf from './AddSelf/AddSelf';
@@ -127,6 +129,7 @@ import RootCAFor from './RootCAFor/RootCAFor';
import SQLAdmin from './SQLAdmin/SQLAdmin';
import SameForestTrust from './SameForestTrust/SameForestTrust';
import SpoofSIDHistory from './SpoofSIDHistory/SpoofSIDHistory';
+import SyncEntraDSUsers from './SyncEntraDSUsers/SyncEntraDSUsers';
import SyncLAPSPassword from './SyncLAPSPassword/SyncLAPSPassword';
import SyncedToADUser from './SyncedToADUser/SyncedToADUser';
import SyncedToEntraDSGroup from './SyncedToEntraDSGroup/SyncedToEntraDSGroup';
@@ -196,6 +199,7 @@ const EdgeInfoComponents = {
AZAvereContributor: AZAvereContributor,
AZContains: AZContains,
AZContributor: AZContributor,
+ AZDomainServicesContributor: AZDomainServicesContributor,
AZExecuteCommand: AZExecuteCommand,
AZGetCertificates: AZGetCertificates,
AZGetKeys: AZGetKeys,
@@ -218,8 +222,10 @@ const EdgeInfoComponents = {
WriteSPN: WriteSPN,
AddSelf: AddSelf,
AddKeyCredentialLink: AddKeyCredentialLink,
+ AddEntraDSGroupMember: AddEntraDSGroupMember,
DCSync: DCSync,
SyncLAPSPassword: SyncLAPSPassword,
+ SyncEntraDSUsers: SyncEntraDSUsers,
WriteAccountRestrictions: WriteAccountRestrictions,
WriteGPLink: WriteGPLink,
DumpSMSAPassword: DumpSMSAPassword,
diff --git a/packages/javascript/bh-shared-ui/src/utils/content.ts b/packages/javascript/bh-shared-ui/src/utils/content.ts
index 3080f0e54299..488005d085e6 100644
--- a/packages/javascript/bh-shared-ui/src/utils/content.ts
+++ b/packages/javascript/bh-shared-ui/src/utils/content.ts
@@ -67,6 +67,8 @@ export const entityInformationEndpoints: Record
apiClient.getAZEntityInfoV2('function-apps', id, undefined, false, undefined, undefined, undefined, options),
+ [AzureNodeKind.DomainService]: (id: string, options?: RequestOptions) =>
+ apiClient.getAZEntityInfoV2('domain-services', id, undefined, false, undefined, undefined, undefined, options),
[AzureNodeKind.Group]: (id: string, options?: RequestOptions) =>
apiClient.getAZEntityInfoV2('groups', id, undefined, false, undefined, undefined, undefined, options),
[AzureNodeKind.KeyVault]: (id: string, options?: RequestOptions) =>
diff --git a/packages/javascript/bh-shared-ui/src/utils/icons.ts b/packages/javascript/bh-shared-ui/src/utils/icons.ts
index 2ca97a477dc6..e1d3320c7317 100644
--- a/packages/javascript/bh-shared-ui/src/utils/icons.ts
+++ b/packages/javascript/bh-shared-ui/src/utils/icons.ts
@@ -183,6 +183,11 @@ export const NODE_ICONS: IconDictionary = {
color: '#F4BA44',
},
+ [AzureNodeKind.DomainService]: {
+ icon: faServer,
+ color: '#6D83F2',
+ },
+
[AzureNodeKind.ContainerRegistry]: {
icon: faBoxOpen,
color: '#0885D7',
diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
index 8cecd5da81ad..6fb673e52456 100644
--- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
@@ -217,13 +217,19 @@ export const BUILTIN_EDGE_CATEGORIES: Category[] = [
edgeTypes: [
AzureRelationshipKind.AKSContributor,
AzureRelationshipKind.AutomationContributor,
+ AzureRelationshipKind.DomainServicesContributor,
AzureRelationshipKind.LogicAppContributor,
AzureRelationshipKind.WebsiteContributor,
],
},
{
name: 'Cross Platform',
- edgeTypes: [AzureRelationshipKind.SyncedToEntraUser, AzureRelationshipKind.SyncedToEntraDSUser],
+ edgeTypes: [
+ AzureRelationshipKind.SyncedToEntraUser,
+ AzureRelationshipKind.SyncedToEntraDSUser,
+ AzureRelationshipKind.AddEntraDSGroupMember,
+ AzureRelationshipKind.SyncEntraDSUsers,
+ ],
},
],
},
From 05bfe33cf45d5311b2204b34514dc36760b1ec1f Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Wed, 5 Aug 2026 22:44:40 +0200
Subject: [PATCH 08/18] Rename DomainService nodes-edges to EntraDS
---
cmd/api/src/api/v2/azure.go | 2 +-
.../migration/extensions/az_graph_schema.sql | 10 +++++-----
.../src/services/graphify/azure_convertors.go | 4 ++--
cmd/ui/src/ducks/graph/graphutils.ts | 2 +-
cmd/ui/src/ducks/graph/types.ts | 2 +-
packages/cue/bh/azure/azure.cue | 20 +++++++++----------
packages/go/analysis/ad/entra_ds.go | 2 +-
packages/go/analysis/hybrid/hybrid.go | 6 +++---
.../hybrid/hybrid_integration_test.go | 14 ++++++-------
.../go/analysis/post/post_integration_test.go | 2 +-
packages/go/ein/azure.go | 2 +-
packages/go/ein/azure_domain_service.go | 6 +++---
packages/go/ein/azure_domain_service_test.go | 8 ++++----
packages/go/graphschema/azure/azure.go | 12 +++++------
packages/go/graphschema/common/common.go | 4 ++--
packages/go/schemagen/generator/sql.go | 2 +-
.../bh-shared-ui/src/commonSearchesAGI.ts | 4 ++--
.../bh-shared-ui/src/commonSearchesAGT.ts | 4 ++--
.../bh-shared-ui/src/graphSchema.ts | 14 ++++++-------
.../bh-shared-ui/src/utils/content.ts | 2 +-
.../bh-shared-ui/src/utils/icons.ts | 2 +-
.../EdgeFilter/edgeCategories.tsx | 2 +-
schemas/valid_edges.json | 2 +-
23 files changed, 64 insertions(+), 64 deletions(-)
diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go
index cc179bdcaabd..bb7eda38c1e9 100644
--- a/cmd/api/src/api/v2/azure.go
+++ b/cmd/api/src/api/v2/azure.go
@@ -559,7 +559,7 @@ func azEntityParamToKind(entityType string) (graph.Kind, error) {
return azure_schema.FunctionApp, nil
case entityTypeDomainServices:
- return azure_schema.DomainService, nil
+ return azure_schema.EntraDS, nil
case entityTypeFederatedIdentityCredentials:
return azure_schema.FederatedIdentityCredential, nil
diff --git a/cmd/api/src/database/migration/extensions/az_graph_schema.sql b/cmd/api/src/database/migration/extensions/az_graph_schema.sql
index 319a5a1f4752..f45499a6235d 100644
--- a/cmd/api/src/database/migration/extensions/az_graph_schema.sql
+++ b/cmd/api/src/database/migration/extensions/az_graph_schema.sql
@@ -161,7 +161,7 @@ BEGIN
PERFORM genscript_upsert_kind('AZRole');
PERFORM genscript_upsert_kind('AZDevice');
PERFORM genscript_upsert_kind('AZFunctionApp');
- PERFORM genscript_upsert_kind('AZDomainService');
+ PERFORM genscript_upsert_kind('AZEntraDS');
PERFORM genscript_upsert_kind('AZGroup');
PERFORM genscript_upsert_kind('AZKeyVault');
PERFORM genscript_upsert_kind('AZManagementGroup');
@@ -182,7 +182,7 @@ BEGIN
PERFORM genscript_upsert_kind('AZAvereContributor');
PERFORM genscript_upsert_kind('AZContains');
PERFORM genscript_upsert_kind('AZContributor');
- PERFORM genscript_upsert_kind('AZDomainServicesContributor');
+ PERFORM genscript_upsert_kind('AZEntraDSContributor');
PERFORM genscript_upsert_kind('AZGetCertificates');
PERFORM genscript_upsert_kind('AZGetKeys');
PERFORM genscript_upsert_kind('AZGetSecrets');
@@ -241,7 +241,7 @@ BEGIN
PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZRole', 'AZRole', '', true, 'clipboard-list', '#ED8537');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZDevice', 'AZDevice', '', true, 'desktop', '#B18FCF');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZFunctionApp', 'AZFunctionApp', '', true, 'bolt', '#F4BA44');
- PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZDomainService', 'AZDomainService', '', true, 'server', '#6D83F2');
+ PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZEntraDS', 'AZEntraDS', '', true, 'server', '#6D83F2');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZGroup', 'AZGroup', '', true, 'users', '#F57C9B');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZKeyVault', 'AZKeyVault', '', true, 'lock', '#ED658C');
PERFORM genscript_upsert_schema_node_kind(extension_id, 'AZManagementGroup', 'AZManagementGroup', '', true, 'sitemap', '#BD93D8');
@@ -264,7 +264,7 @@ BEGIN
PERFORM genscript_upsert_custom_node_kind('AZRole', '{"icon": {"name": "clipboard-list", "type": "font-awesome", "color": "#ED8537"}}');
PERFORM genscript_upsert_custom_node_kind('AZDevice', '{"icon": {"name": "desktop", "type": "font-awesome", "color": "#B18FCF"}}');
PERFORM genscript_upsert_custom_node_kind('AZFunctionApp', '{"icon": {"name": "bolt", "type": "font-awesome", "color": "#F4BA44"}}');
- PERFORM genscript_upsert_custom_node_kind('AZDomainService', '{"icon": {"name": "server", "type": "font-awesome", "color": "#6D83F2"}}');
+ PERFORM genscript_upsert_custom_node_kind('AZEntraDS', '{"icon": {"name": "server", "type": "font-awesome", "color": "#6D83F2"}}');
PERFORM genscript_upsert_custom_node_kind('AZGroup', '{"icon": {"name": "users", "type": "font-awesome", "color": "#F57C9B"}}');
PERFORM genscript_upsert_custom_node_kind('AZKeyVault', '{"icon": {"name": "lock", "type": "font-awesome", "color": "#ED658C"}}');
PERFORM genscript_upsert_custom_node_kind('AZManagementGroup', '{"icon": {"name": "sitemap", "type": "font-awesome", "color": "#BD93D8"}}');
@@ -284,7 +284,7 @@ BEGIN
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZAvereContributor', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZContains', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZContributor', '', true);
- PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZDomainServicesContributor', '', true);
+ PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZEntraDSContributor', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZGetCertificates', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZGetKeys', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZGetSecrets', '', true);
diff --git a/cmd/api/src/services/graphify/azure_convertors.go b/cmd/api/src/services/graphify/azure_convertors.go
index a7394d8c26a5..db7c4e3949a1 100644
--- a/cmd/api/src/services/graphify/azure_convertors.go
+++ b/cmd/api/src/services/graphify/azure_convertors.go
@@ -55,9 +55,9 @@ func getKindConverter(kind enums.Kind) func(json.RawMessage, *ConvertedAzureData
return convertAzureFunctionApp
case enums.KindAZFunctionAppRoleAssignment:
return convertAzureFunctionAppRoleAssignment
- case enums.Kind("AZDomainService"):
+ case enums.Kind("AZEntraDS"):
return convertAzureDomainService
- case enums.Kind("AZDomainServiceRoleAssignment"):
+ case enums.Kind("AZEntraDSRoleAssignment"):
return convertAzureDomainServiceRoleAssignment
case enums.KindAZGroup:
return convertAzureGroup
diff --git a/cmd/ui/src/ducks/graph/graphutils.ts b/cmd/ui/src/ducks/graph/graphutils.ts
index 377d1dc3f5d4..a387ba337b78 100644
--- a/cmd/ui/src/ducks/graph/graphutils.ts
+++ b/cmd/ui/src/ducks/graph/graphutils.ts
@@ -208,7 +208,7 @@ const ICONS: { [id in GraphNodeTypes]: string } = {
[GraphNodeTypes.AZRole]: 'fa-window-restore',
[GraphNodeTypes.AZDevice]: 'fa-desktop',
[GraphNodeTypes.AZFunctionApp]: 'fa-bolt',
- [GraphNodeTypes.AZDomainService]: 'fa-server',
+ [GraphNodeTypes.AZEntraDS]: 'fa-server',
[GraphNodeTypes.AZGroup]: 'fa-users',
[GraphNodeTypes.AZKeyVault]: 'fa-lock',
[GraphNodeTypes.AZManagementGroup]: 'fa-cube',
diff --git a/cmd/ui/src/ducks/graph/types.ts b/cmd/ui/src/ducks/graph/types.ts
index 8576cf9eb6e1..e6f8d0ba5f1b 100644
--- a/cmd/ui/src/ducks/graph/types.ts
+++ b/cmd/ui/src/ducks/graph/types.ts
@@ -21,7 +21,7 @@ export enum GraphNodeTypes {
AZRole = 'AZRole',
AZDevice = 'AZDevice',
AZFunctionApp = 'AZFunctionApp',
- AZDomainService = 'AZDomainService',
+ AZEntraDS = 'AZEntraDS',
AZGroup = 'AZGroup',
AZKeyVault = 'AZKeyVault',
AZManagementGroup = 'AZManagementGroup',
diff --git a/packages/cue/bh/azure/azure.cue b/packages/cue/bh/azure/azure.cue
index 4eb673bf1ed2..079c216fdde5 100644
--- a/packages/cue/bh/azure/azure.cue
+++ b/packages/cue/bh/azure/azure.cue
@@ -559,10 +559,10 @@ FunctionApp: types.#Kind & {
representation: "AZFunctionApp"
}
-DomainService: types.#Kind & {
- symbol: "DomainService"
+EntraDS: types.#Kind & {
+ symbol: "EntraDS"
schema: "azure"
- representation: "AZDomainService"
+ representation: "AZEntraDS"
}
Group: types.#Kind & {
@@ -662,7 +662,7 @@ NodeKinds: [
Role,
Device,
FunctionApp,
- DomainService,
+ EntraDS,
Group,
KeyVault,
ManagementGroup,
@@ -800,10 +800,10 @@ Contributor: types.#Kind & {
representation: "AZContributor"
}
-DomainServicesContributor: types.#Kind & {
- symbol: "DomainServicesContributor"
+EntraDSContributor: types.#Kind & {
+ symbol: "EntraDSContributor"
schema: "azure"
- representation: "AZDomainServicesContributor"
+ representation: "AZEntraDSContributor"
}
GetCertificates: types.#Kind & {
@@ -1013,7 +1013,7 @@ RelationshipKinds: [
AvereContributor,
Contains,
Contributor,
- DomainServicesContributor,
+ EntraDSContributor,
GetCertificates,
GetKeys,
GetSecrets,
@@ -1088,7 +1088,7 @@ AbusableAppRoleRelationshipKinds: [
ControlRelationshipKinds: [
AvereContributor,
Contributor,
- DomainServicesContributor,
+ EntraDSContributor,
Owner,
VMContributor,
AutomationContributor,
@@ -1131,7 +1131,7 @@ ExecutionPrivilegeKinds: [
InboundOutboundRelationshipKinds: [
AvereContributor,
Contributor,
- DomainServicesContributor,
+ EntraDSContributor,
GetCertificates,
GetKeys,
GetSecrets,
diff --git a/packages/go/analysis/ad/entra_ds.go b/packages/go/analysis/ad/entra_ds.go
index 739022ead1ab..5406a84704ce 100644
--- a/packages/go/analysis/ad/entra_ds.go
+++ b/packages/go/analysis/ad/entra_ds.go
@@ -26,7 +26,7 @@ import (
)
// GetAddEntraDSGroupMemberEdgeComposition reconstructs the paths that compose an AddEntraDSGroupMember edge. The
-// edge's start node is the AZUser and its end node is the on-prem Group it can add a member to. The composition is:
+// edge's start node is the AZUser and its end node is the on-prem Group whose membership it can modify. The composition is:
//
// p1 = (azUser)-[:SyncedToEntraDSUser]->(:User)
// p2 = (azUser)-[:AZOwns|AZAddMembers]->(azGroup:AZGroup)
diff --git a/packages/go/analysis/hybrid/hybrid.go b/packages/go/analysis/hybrid/hybrid.go
index e0f02cd6e60d..1f940d9ba1ea 100644
--- a/packages/go/analysis/hybrid/hybrid.go
+++ b/packages/go/analysis/hybrid/hybrid.go
@@ -170,7 +170,7 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
}
// Now that we know which AZ users and AZ groups are synced to Entra Domain Services, compute the
- // AddEntraDSGroupMember edges (an Entra DS-synced AZUser that can add members to an Entra DS-synced AZGroup)
+ // AddEntraDSGroupMember edges (an Entra DS-synced AZUser that can add or remove members from an Entra DS-synced AZGroup)
if err := addAddEntraDSGroupMemberEdges(tx, syncedToEntraDSUserEdgeMap, syncedToEntraDSGroupEdgeMap, addEntraDSGroupMemberEdgeMap); err != nil {
return err
}
@@ -479,7 +479,7 @@ func addSyncEntraDSUsersEdge(syncEntraDSUsersEdgeMap map[graph.ID][]graph.ID, se
}
// addAddEntraDSGroupMemberEdges computes the AddEntraDSGroupMember edges. An edge is created from an AZUser to an
-// on-prem Group when the AZUser is synced to Entra Domain Services, the AZUser owns or can add members to an AZGroup
+// on-prem Group when the AZUser is synced to Entra Domain Services, the AZUser owns or can add and remove members from an AZGroup
// (AZOwns / AZAddMembers), and that AZGroup is itself synced to Entra Domain Services. The resulting edge is drawn
// from the AZUser to the on-prem Group that the AZGroup is synced to.
func addAddEntraDSGroupMemberEdges(tx graph.Transaction, syncedToEntraDSUserEdgeMap, syncedToEntraDSGroupEdgeMap, addEntraDSGroupMemberEdgeMap map[graph.ID][]graph.ID) error {
@@ -590,7 +590,7 @@ func fetchEntraGroups(tx graph.Transaction, root *graph.Node) (graph.NodeSet, er
func fetchEntraDomainServices(tx graph.Transaction) ([]*graph.Node, error) {
return ops.FetchNodes(tx.Nodes().Filterf(func() graph.Criteria {
- return query.Kind(query.Node(), azure.DomainService)
+ return query.Kind(query.Node(), azure.EntraDS)
}))
}
diff --git a/packages/go/analysis/hybrid/hybrid_integration_test.go b/packages/go/analysis/hybrid/hybrid_integration_test.go
index 587b0bc26141..adfd80dae40e 100644
--- a/packages/go/analysis/hybrid/hybrid_integration_test.go
+++ b/packages/go/analysis/hybrid/hybrid_integration_test.go
@@ -493,7 +493,7 @@ func TestSyncEntraDSUsersEdge(t *testing.T) {
}
verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.DomainService,
+ getObjectID(t, domainService): azure.EntraDS,
})
},
)
@@ -522,7 +522,7 @@ func TestSyncEntraDSUsersEdge(t *testing.T) {
}
verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.DomainService,
+ getObjectID(t, domainService): azure.EntraDS,
getObjectID(t, servicePrincipal): azure.ServicePrincipal,
})
},
@@ -552,7 +552,7 @@ func TestSyncEntraDSUsersEdge(t *testing.T) {
}
verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.DomainService,
+ getObjectID(t, domainService): azure.EntraDS,
})
},
)
@@ -581,7 +581,7 @@ func TestSyncEntraDSUsersEdge(t *testing.T) {
}
verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.DomainService,
+ getObjectID(t, domainService): azure.EntraDS,
})
},
)
@@ -610,7 +610,7 @@ func TestSyncEntraDSUsersEdge(t *testing.T) {
}
verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.DomainService,
+ getObjectID(t, domainService): azure.EntraDS,
})
},
)
@@ -717,7 +717,7 @@ func TestSyncEntraDSUsersEdge(t *testing.T) {
}
verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.DomainService,
+ getObjectID(t, domainService): azure.EntraDS,
})
},
)
@@ -771,7 +771,7 @@ func setupSyncEntraDSUsersHarness(t *testing.T, testContext *integration.GraphTe
azure.TenantID: adminGroupTenantID,
azure.FilteredSync: filteredSync,
azure.SyncScope: syncScope,
- }), azure.Entity, azure.DomainService)
+ }), azure.Entity, azure.EntraDS)
application = testContext.NewAzureApplication("Domain Controller Services", options.applicationID, servicePrincipalTenantID)
servicePrincipal = testContext.NewAzureServicePrincipal("Domain Controller Services", integration.RandomObjectID(t), servicePrincipalTenantID)
azAdminGroupObjectID := integration.RandomObjectID(t)
diff --git a/packages/go/analysis/post/post_integration_test.go b/packages/go/analysis/post/post_integration_test.go
index fb1cb06825fb..809d4ee1af79 100644
--- a/packages/go/analysis/post/post_integration_test.go
+++ b/packages/go/analysis/post/post_integration_test.go
@@ -73,7 +73,7 @@ func TestDeleteTransitEdges(t *testing.T) {
domainService = testCtx.NewNode(graph.AsProperties(map[string]any{
"name": "managed_domain",
"objectid": "5678",
- }), azure.Entity, azure.DomainService)
+ }), azure.Entity, azure.EntraDS)
)
// In order to validate that DeleteTransitEdges and the updated PostProcessedRelationships for both AD and Azure are correct, we need to simulate
diff --git a/packages/go/ein/azure.go b/packages/go/ein/azure.go
index de8e6b7b7443..eaeaed2006ae 100644
--- a/packages/go/ein/azure.go
+++ b/packages/go/ein/azure.go
@@ -2106,7 +2106,7 @@ func KindFromRoleId(roleId string) graph.Kind {
case constants.ContributorRoleID:
return azure.Contributor
case constants.DomainServicesContributorRoleID:
- return azure.DomainServicesContributor
+ return azure.EntraDSContributor
case constants.WebsiteContributorRoleID:
return azure.WebsiteContributor
case constants.AutomationContributorRoleID:
diff --git a/packages/go/ein/azure_domain_service.go b/packages/go/ein/azure_domain_service.go
index aa66956d083c..1d0c5a320695 100644
--- a/packages/go/ein/azure_domain_service.go
+++ b/packages/go/ein/azure_domain_service.go
@@ -90,7 +90,7 @@ func ConvertAzureDomainServiceToNode(data AzureDomainService, ingestTime time.Ti
azure.LDAPS.String(): data.Properties.LDAPSSettings.LDAPS,
azure.LDAPSExternalAccess.String(): data.Properties.LDAPSSettings.ExternalAccess,
},
- Labels: []graph.Kind{azure.DomainService},
+ Labels: []graph.Kind{azure.EntraDS},
}
}
@@ -106,7 +106,7 @@ func ConvertAzureDomainServiceToRels(data AzureDomainService) []IngestibleRelati
},
IngestibleEndpoint{
Value: data.ID,
- Kind: azure.DomainService,
+ Kind: azure.EntraDS,
},
IngestibleRel{
RelProps: map[string]any{},
@@ -134,7 +134,7 @@ func ConvertAzureDomainServiceRoleAssignmentToRels(data models.AzureRoleAssignme
},
IngestibleEndpoint{
Value: data.ObjectId,
- Kind: azure.DomainService,
+ Kind: azure.EntraDS,
},
IngestibleRel{
RelProps: map[string]any{},
diff --git a/packages/go/ein/azure_domain_service_test.go b/packages/go/ein/azure_domain_service_test.go
index 8d6b775114e4..3e4005be934d 100644
--- a/packages/go/ein/azure_domain_service_test.go
+++ b/packages/go/ein/azure_domain_service_test.go
@@ -66,7 +66,7 @@ func TestConvertAzureDomainServiceToNode(t *testing.T) {
node := ein.ConvertAzureDomainServiceToNode(data, ingestTime)
assert.Equal(t, data.ID, node.ObjectID)
- assert.Equal(t, []graph.Kind{azure.DomainService}, node.Labels)
+ assert.Equal(t, []graph.Kind{azure.EntraDS}, node.Labels)
require.Len(t, node.PropertyMap, 20)
assert.Equal(t, data.Name, node.PropertyMap[common.Name.String()])
assert.Equal(t, ingestTime, node.PropertyMap[common.LastCollected.String()])
@@ -102,7 +102,7 @@ func TestConvertAzureDomainServiceToRels(t *testing.T) {
assert.Equal(t, data.ResourceGroupID, rels[0].Source.Value)
assert.Equal(t, azure.ResourceGroup, rels[0].Source.Kind)
assert.Equal(t, data.ID, rels[0].Target.Value)
- assert.Equal(t, azure.DomainService, rels[0].Target.Kind)
+ assert.Equal(t, azure.EntraDS, rels[0].Target.Kind)
assert.Equal(t, azure.Contains, rels[0].RelType)
assert.Empty(t, rels[0].RelProps)
@@ -124,7 +124,7 @@ func TestConvertAzureDomainServiceRoleAssignmentToRels(t *testing.T) {
{name: "owner", roleDefinitionID: constants.OwnerRoleID, scope: strings.ToLower(resourceID), expectedKind: azure.Owner, expectedCount: 1},
{name: "user access administrator", roleDefinitionID: constants.UserAccessAdminRoleID, scope: resourceID, expectedKind: azure.UserAccessAdministrator, expectedCount: 1},
{name: "contributor", roleDefinitionID: constants.ContributorRoleID, scope: resourceID, expectedKind: azure.Contributor, expectedCount: 1},
- {name: "domain services contributor", roleDefinitionID: constants.DomainServicesContributorRoleID, scope: resourceID, expectedKind: azure.DomainServicesContributor, expectedCount: 1},
+ {name: "domain services contributor", roleDefinitionID: constants.DomainServicesContributorRoleID, scope: resourceID, expectedKind: azure.EntraDSContributor, expectedCount: 1},
{name: "inherited role", roleDefinitionID: constants.OwnerRoleID, scope: "/SUBSCRIPTIONS/SUB/RESOURCEGROUPS/RG", expectedCount: 0},
{name: "unsupported role", roleDefinitionID: "00000000-0000-0000-0000-000000000000", scope: resourceID, expectedCount: 0},
}
@@ -150,7 +150,7 @@ func TestConvertAzureDomainServiceRoleAssignmentToRels(t *testing.T) {
assert.Equal(t, principalID, rels[0].Source.Value)
assert.Equal(t, azure.Entity, rels[0].Source.Kind)
assert.Equal(t, resourceID, rels[0].Target.Value)
- assert.Equal(t, azure.DomainService, rels[0].Target.Kind)
+ assert.Equal(t, azure.EntraDS, rels[0].Target.Kind)
assert.Equal(t, testCase.expectedKind, rels[0].RelType)
assert.Empty(t, rels[0].RelProps)
}
diff --git a/packages/go/graphschema/azure/azure.go b/packages/go/graphschema/azure/azure.go
index 756b2c932a39..26b28ac30990 100644
--- a/packages/go/graphschema/azure/azure.go
+++ b/packages/go/graphschema/azure/azure.go
@@ -31,7 +31,7 @@ var (
Role = graph.StringKind("AZRole")
Device = graph.StringKind("AZDevice")
FunctionApp = graph.StringKind("AZFunctionApp")
- DomainService = graph.StringKind("AZDomainService")
+ EntraDS = graph.StringKind("AZEntraDS")
Group = graph.StringKind("AZGroup")
KeyVault = graph.StringKind("AZKeyVault")
ManagementGroup = graph.StringKind("AZManagementGroup")
@@ -50,7 +50,7 @@ var (
AvereContributor = graph.StringKind("AZAvereContributor")
Contains = graph.StringKind("AZContains")
Contributor = graph.StringKind("AZContributor")
- DomainServicesContributor = graph.StringKind("AZDomainServicesContributor")
+ EntraDSContributor = graph.StringKind("AZEntraDSContributor")
GetCertificates = graph.StringKind("AZGetCertificates")
GetKeys = graph.StringKind("AZGetKeys")
GetSecrets = graph.StringKind("AZGetSecrets")
@@ -566,7 +566,7 @@ func (s Property) Is(others ...graph.Kind) bool {
return false
}
func Relationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contains, Contributor, DomainServicesContributor, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, ScopedTo, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, ApplicationReadWriteAll, AppRoleAssignmentReadWriteAll, DirectoryReadWriteAll, GroupReadWriteAll, GroupMemberReadWriteAll, RoleManagementReadWriteDirectory, ServicePrincipalEndpointReadWriteAll, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, SyncedToEntraDSGroup, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleEligible, AZRoleApprover, AZAuthenticatesTo}
+ return []graph.Kind{AvereContributor, Contains, Contributor, EntraDSContributor, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, ScopedTo, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, ApplicationReadWriteAll, AppRoleAssignmentReadWriteAll, DirectoryReadWriteAll, GroupReadWriteAll, GroupMemberReadWriteAll, RoleManagementReadWriteDirectory, ServicePrincipalEndpointReadWriteAll, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, SyncedToEntraDSGroup, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleEligible, AZRoleApprover, AZAuthenticatesTo}
}
func AppRoleTransitRelationshipKinds() []graph.Kind {
return []graph.Kind{AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole}
@@ -575,17 +575,17 @@ func AbusableAppRoleRelationshipKinds() []graph.Kind {
return []graph.Kind{ApplicationReadWriteAll, AppRoleAssignmentReadWriteAll, DirectoryReadWriteAll, GroupReadWriteAll, GroupMemberReadWriteAll, RoleManagementReadWriteDirectory, ServicePrincipalEndpointReadWriteAll}
}
func ControlRelationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contributor, DomainServicesContributor, Owner, VMContributor, AutomationContributor, KeyVaultContributor, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, AZAuthenticatesTo}
+ return []graph.Kind{AvereContributor, Contributor, EntraDSContributor, Owner, VMContributor, AutomationContributor, KeyVaultContributor, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, AZAuthenticatesTo}
}
func ExecutionPrivileges() []graph.Kind {
return []graph.Kind{VMAdminLogin, VMContributor, AvereContributor, WebsiteContributor, Contributor, ExecuteCommand}
}
func PathfindingRelationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contributor, DomainServicesContributor, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleEligible, AZRoleApprover, Contains, AZAuthenticatesTo}
+ return []graph.Kind{AvereContributor, Contributor, EntraDSContributor, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleEligible, AZRoleApprover, Contains, AZAuthenticatesTo}
}
func PostProcessedRelationships() []graph.Kind {
return []graph.Kind{ExecuteCommand, SyncedToEntraUser, SyncedToEntraDSUser, SyncedToEntraDSGroup, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleApprover}
}
func NodeKinds() []graph.Kind {
- return []graph.Kind{Entity, VMScaleSet, App, Role, Device, FunctionApp, DomainService, Group, KeyVault, ManagementGroup, ResourceGroup, ServicePrincipal, Subscription, Tenant, User, VM, ManagedCluster, ContainerRegistry, WebApp, LogicApp, AutomationAccount, FederatedIdentityCredential}
+ return []graph.Kind{Entity, VMScaleSet, App, Role, Device, FunctionApp, EntraDS, Group, KeyVault, ManagementGroup, ResourceGroup, ServicePrincipal, Subscription, Tenant, User, VM, ManagedCluster, ContainerRegistry, WebApp, LogicApp, AutomationAccount, FederatedIdentityCredential}
}
diff --git a/packages/go/graphschema/common/common.go b/packages/go/graphschema/common/common.go
index d9116127524a..64a344219ff7 100644
--- a/packages/go/graphschema/common/common.go
+++ b/packages/go/graphschema/common/common.go
@@ -40,10 +40,10 @@ func NodeKinds() []graph.Kind {
return []graph.Kind{MigrationData}
}
func InboundRelationshipKinds() []graph.Kind {
- return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, azure.AvereContributor, azure.Contributor, azure.DomainServicesContributor, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.SyncEntraDSUsers, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
+ return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, azure.AvereContributor, azure.Contributor, azure.EntraDSContributor, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.SyncEntraDSUsers, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
}
func OutboundRelationshipKinds() []graph.Kind {
- return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, ad.DCFor, azure.AvereContributor, azure.Contributor, azure.DomainServicesContributor, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.SyncEntraDSUsers, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
+ return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, ad.DCFor, azure.AvereContributor, azure.Contributor, azure.EntraDSContributor, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.SyncEntraDSUsers, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
}
type Property string
diff --git a/packages/go/schemagen/generator/sql.go b/packages/go/schemagen/generator/sql.go
index 66056a72068d..a6e371392c5f 100644
--- a/packages/go/schemagen/generator/sql.go
+++ b/packages/go/schemagen/generator/sql.go
@@ -125,7 +125,7 @@ var nodeIcons = map[string]nodeIcon{
Icon: "bolt",
Color: "#F4BA44",
},
- "AZDomainService": {
+ "AZEntraDS": {
Icon: "server",
Color: "#6D83F2",
},
diff --git a/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts b/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
index 7e8bc94d8ebe..93a5f56fd111 100644
--- a/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
+++ b/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
@@ -484,12 +484,12 @@ RETURN p\nLIMIT 1000`,
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (direct)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZDomainServicesContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (group delegated)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZDomainServicesContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users that Own Entra Objects',
diff --git a/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts b/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
index d7e8dc118c69..0e8772100e75 100644
--- a/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
+++ b/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
@@ -484,12 +484,12 @@ RETURN p\nLIMIT 1000`,
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (direct)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZDomainServicesContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (group delegated)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZDomainServicesContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users that Own Entra Objects',
diff --git a/packages/javascript/bh-shared-ui/src/graphSchema.ts b/packages/javascript/bh-shared-ui/src/graphSchema.ts
index 9f3fdace9a8d..56ad3fa32686 100644
--- a/packages/javascript/bh-shared-ui/src/graphSchema.ts
+++ b/packages/javascript/bh-shared-ui/src/graphSchema.ts
@@ -932,7 +932,7 @@ export enum AzureNodeKind {
Role = 'AZRole',
Device = 'AZDevice',
FunctionApp = 'AZFunctionApp',
- DomainService = 'AZDomainService',
+ EntraDS = 'AZEntraDS',
Group = 'AZGroup',
KeyVault = 'AZKeyVault',
ManagementGroup = 'AZManagementGroup',
@@ -963,8 +963,8 @@ export function AzureNodeKindToDisplay(value: AzureNodeKind): string | undefined
return 'Device';
case AzureNodeKind.FunctionApp:
return 'FunctionApp';
- case AzureNodeKind.DomainService:
- return 'DomainService';
+ case AzureNodeKind.EntraDS:
+ return 'EntraDS';
case AzureNodeKind.Group:
return 'Group';
case AzureNodeKind.KeyVault:
@@ -1003,7 +1003,7 @@ export enum AzureRelationshipKind {
AvereContributor = 'AZAvereContributor',
Contains = 'AZContains',
Contributor = 'AZContributor',
- DomainServicesContributor = 'AZDomainServicesContributor',
+ EntraDSContributor = 'AZEntraDSContributor',
GetCertificates = 'AZGetCertificates',
GetKeys = 'AZGetKeys',
GetSecrets = 'AZGetSecrets',
@@ -1064,8 +1064,8 @@ export function AzureRelationshipKindToDisplay(value: AzureRelationshipKind): st
return 'Contains';
case AzureRelationshipKind.Contributor:
return 'Contributor';
- case AzureRelationshipKind.DomainServicesContributor:
- return 'DomainServicesContributor';
+ case AzureRelationshipKind.EntraDSContributor:
+ return 'EntraDSContributor';
case AzureRelationshipKind.GetCertificates:
return 'GetCertificates';
case AzureRelationshipKind.GetKeys:
@@ -1368,7 +1368,7 @@ export function AzurePathfindingEdges(): AzureRelationshipKind[] {
return [
AzureRelationshipKind.AvereContributor,
AzureRelationshipKind.Contributor,
- AzureRelationshipKind.DomainServicesContributor,
+ AzureRelationshipKind.EntraDSContributor,
AzureRelationshipKind.GetCertificates,
AzureRelationshipKind.GetKeys,
AzureRelationshipKind.GetSecrets,
diff --git a/packages/javascript/bh-shared-ui/src/utils/content.ts b/packages/javascript/bh-shared-ui/src/utils/content.ts
index 488005d085e6..b4e986d85751 100644
--- a/packages/javascript/bh-shared-ui/src/utils/content.ts
+++ b/packages/javascript/bh-shared-ui/src/utils/content.ts
@@ -67,7 +67,7 @@ export const entityInformationEndpoints: Record
apiClient.getAZEntityInfoV2('function-apps', id, undefined, false, undefined, undefined, undefined, options),
- [AzureNodeKind.DomainService]: (id: string, options?: RequestOptions) =>
+ [AzureNodeKind.EntraDS]: (id: string, options?: RequestOptions) =>
apiClient.getAZEntityInfoV2('domain-services', id, undefined, false, undefined, undefined, undefined, options),
[AzureNodeKind.Group]: (id: string, options?: RequestOptions) =>
apiClient.getAZEntityInfoV2('groups', id, undefined, false, undefined, undefined, undefined, options),
diff --git a/packages/javascript/bh-shared-ui/src/utils/icons.ts b/packages/javascript/bh-shared-ui/src/utils/icons.ts
index e1d3320c7317..969b85f65867 100644
--- a/packages/javascript/bh-shared-ui/src/utils/icons.ts
+++ b/packages/javascript/bh-shared-ui/src/utils/icons.ts
@@ -183,7 +183,7 @@ export const NODE_ICONS: IconDictionary = {
color: '#F4BA44',
},
- [AzureNodeKind.DomainService]: {
+ [AzureNodeKind.EntraDS]: {
icon: faServer,
color: '#6D83F2',
},
diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
index 6fb673e52456..94c20c2686a2 100644
--- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
@@ -217,7 +217,7 @@ export const BUILTIN_EDGE_CATEGORIES: Category[] = [
edgeTypes: [
AzureRelationshipKind.AKSContributor,
AzureRelationshipKind.AutomationContributor,
- AzureRelationshipKind.DomainServicesContributor,
+ AzureRelationshipKind.EntraDSContributor,
AzureRelationshipKind.LogicAppContributor,
AzureRelationshipKind.WebsiteContributor,
],
diff --git a/schemas/valid_edges.json b/schemas/valid_edges.json
index e5027e9be67f..3676c0412e77 100644
--- a/schemas/valid_edges.json
+++ b/schemas/valid_edges.json
@@ -21,7 +21,7 @@
}
},
{
- "source": "AZDomainService",
+ "source": "AZEntraDS",
"target": "Group",
"edges": {
"ingest": [],
From 2b0b009bca4fb35b794975d055c28a05132ec76c Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Mon, 10 Aug 2026 14:59:01 +0200
Subject: [PATCH 09/18] fix(schemagen): support portable schema overlays
---
packages/go/schemagen/main.go | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/packages/go/schemagen/main.go b/packages/go/schemagen/main.go
index 8628e1fc4f00..2f0a34d4afad 100644
--- a/packages/go/schemagen/main.go
+++ b/packages/go/schemagen/main.go
@@ -59,10 +59,15 @@ func GenerateGolang(projectRoot string, rootSchema Schema) error {
}
func GenerateSharedTypeScript(projectRoot string, rootSchema Schema) error {
- root := tsgen.NewFile("graph_schema", filepath.Join(projectRoot, "packages/javascript/bh-shared-ui/src/graphSchema.ts"))
+ var (
+ root = tsgen.NewFile("graph_schema", filepath.Join(projectRoot, "packages/javascript/bh-shared-ui/src/graphSchema.ts"))
+ edgeCompositionRelationships = append([]model.StringEnum{}, rootSchema.ActiveDirectory.EdgeCompositionRelationships...)
+ )
+ edgeCompositionRelationships = append(edgeCompositionRelationships, rootSchema.Azure.EdgeCompositionRelationships...)
generator.GenerateTypeScriptActiveDirectory(root, rootSchema.ActiveDirectory)
generator.GenerateTypeScriptAzure(root, rootSchema.Azure)
+ generator.GenerateTypeScriptArray(root, "EdgeCompositionRelationships", edgeCompositionRelationships)
generator.GenerateTypeScriptCommon(root, rootSchema.Common)
return root.Write(os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644)
@@ -85,13 +90,17 @@ func GenerateSQL(projectRoot string, rootSchema Schema) error {
}
func main() {
- cfgBuilder := generator.NewConfigBuilder("/schemas")
-
if projectRoot, err := generator.FindGolangWorkspaceRoot(); err != nil {
slog.Error("Error finding project root", attr.Error(err))
os.Exit(1)
} else {
slog.Info("Found project root", slog.String("project_root", projectRoot))
+ overlayVolumeRoot := string(filepath.Separator)
+ if volumeName := filepath.VolumeName(projectRoot); volumeName != "" {
+ overlayVolumeRoot = volumeName + string(filepath.Separator)
+ }
+ overlayRoot := filepath.Join(overlayVolumeRoot, "schemas")
+ cfgBuilder := generator.NewConfigBuilder(overlayRoot)
if err := cfgBuilder.OverlayPath(filepath.Join(projectRoot, "packages/cue")); err != nil {
slog.Error("Failed to read overlay path", attr.Error(err))
@@ -100,7 +109,7 @@ func main() {
cfg := cfgBuilder.Build()
- if bhInstance, err := cfg.Value("/schemas/bh/bh.cue"); err != nil {
+ if bhInstance, err := cfg.Value(filepath.Join(overlayRoot, "bh", "bh.cue")); err != nil {
slog.Error("Failed to load cue schema", slog.String("err", errors.Details(err, nil)))
os.Exit(1)
} else {
From 3f22e7dc63b6d52f12c2b903e9f80c761b9d711b Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Mon, 10 Aug 2026 16:23:42 +0200
Subject: [PATCH 10/18] feat: add Entra DS graph model and analysis
---
cmd/api/src/api/v2/edge.go | 3 +-
.../migration/extensions/az_graph_schema.sql | 12 +-
packages/csharp/graphschema/PropertyNames.cs | 8 +-
packages/cue/bh/azure/azure.cue | 177 +++---
packages/cue/bh/bh.cue | 2 +
packages/go/analysis/ad/ad.go | 3 -
packages/go/analysis/ad/entra_ds.go | 96 ---
.../analysis/azure/azure_integration_test.go | 105 ++++
.../analysis/azure/entra_domain_services.go | 363 +++++++++++
packages/go/analysis/azure/post.go | 3 +
.../edgecomposition/edgecomposition.go | 44 ++
packages/go/analysis/hybrid/composition.go | 172 +++++
packages/go/analysis/hybrid/hybrid.go | 286 ++++++---
.../hybrid/hybrid_integration_test.go | 585 +++++++++---------
.../go/analysis/post/post_integration_test.go | 23 +-
packages/go/ein/azure_domain_service.go | 54 +-
packages/go/ein/azure_domain_service_test.go | 47 +-
packages/go/graphschema/azure/azure.go | 197 +++---
packages/go/graphschema/azure/azure_test.go | 51 ++
packages/go/graphschema/common/common.go | 4 +-
packages/go/schemagen/generator/typescript.go | 2 -
packages/go/schemagen/model/schema.go | 1 +
.../bh-shared-ui/src/graphSchema.ts | 139 +++--
schemas/valid_edges.json | 82 ++-
24 files changed, 1699 insertions(+), 760 deletions(-)
delete mode 100644 packages/go/analysis/ad/entra_ds.go
create mode 100644 packages/go/analysis/azure/entra_domain_services.go
create mode 100644 packages/go/analysis/edgecomposition/edgecomposition.go
create mode 100644 packages/go/analysis/hybrid/composition.go
create mode 100644 packages/go/graphschema/azure/azure_test.go
diff --git a/cmd/api/src/api/v2/edge.go b/cmd/api/src/api/v2/edge.go
index b16cdc32ed08..327fbaf58d13 100644
--- a/cmd/api/src/api/v2/edge.go
+++ b/cmd/api/src/api/v2/edge.go
@@ -23,6 +23,7 @@ import (
"github.com/specterops/bloodhound/cmd/api/src/model"
"github.com/specterops/bloodhound/packages/go/analysis"
+ "github.com/specterops/bloodhound/packages/go/analysis/edgecomposition"
"github.com/specterops/bloodhound/packages/go/ein"
"github.com/specterops/bloodhound/cmd/api/src/api"
@@ -99,7 +100,7 @@ func (s *Resources) GetEdgeComposition(response http.ResponseWriter, request *ht
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("Invalid value for endID: %s", targetNode[0]), request), response)
} else if edge, err := analysis.FetchEdgeByStartAndEnd(request.Context(), s.Graph, graph.ID(startID), graph.ID(endID), kind); err != nil {
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("Could not find edge matching criteria: %v", err), request), response)
- } else if pathSet, err := ad.GetEdgeCompositionPath(request.Context(), s.Graph, edge); err != nil {
+ } else if pathSet, err := edgecomposition.GetEdgeCompositionPath(request.Context(), s.Graph, edge); err != nil {
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("Error getting composition for edge: %v", err), request), response)
} else if primaryDisplayKinds, err := s.DB.GetPrimaryDisplayKinds(request.Context()); err != nil {
api.HandleDatabaseError(request, response, err)
diff --git a/cmd/api/src/database/migration/extensions/az_graph_schema.sql b/cmd/api/src/database/migration/extensions/az_graph_schema.sql
index f45499a6235d..934ac3297722 100644
--- a/cmd/api/src/database/migration/extensions/az_graph_schema.sql
+++ b/cmd/api/src/database/migration/extensions/az_graph_schema.sql
@@ -183,6 +183,7 @@ BEGIN
PERFORM genscript_upsert_kind('AZContains');
PERFORM genscript_upsert_kind('AZContributor');
PERFORM genscript_upsert_kind('AZEntraDSContributor');
+ PERFORM genscript_upsert_kind('AZManageEntraDS');
PERFORM genscript_upsert_kind('AZGetCertificates');
PERFORM genscript_upsert_kind('AZGetKeys');
PERFORM genscript_upsert_kind('AZGetSecrets');
@@ -230,7 +231,9 @@ BEGIN
PERFORM genscript_upsert_kind('SyncedToEntraDSUser');
PERFORM genscript_upsert_kind('SyncedToEntraDSGroup');
PERFORM genscript_upsert_kind('AddEntraDSGroupMember');
- PERFORM genscript_upsert_kind('SyncEntraDSUsers');
+ PERFORM genscript_upsert_kind('EntraDSFor');
+ PERFORM genscript_upsert_kind('ManageEntraDSSync');
+ PERFORM genscript_upsert_kind('ManageEntraDSSyncFilter');
PERFORM genscript_upsert_kind('AZRoleEligible');
PERFORM genscript_upsert_kind('AZRoleApprover');
PERFORM genscript_upsert_kind('AZAuthenticatesTo');
@@ -284,7 +287,8 @@ BEGIN
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZAvereContributor', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZContains', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZContributor', '', true);
- PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZEntraDSContributor', '', true);
+ PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZEntraDSContributor', '', false);
+ PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZManageEntraDS', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZGetCertificates', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZGetKeys', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZGetSecrets', '', true);
@@ -332,7 +336,9 @@ BEGIN
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'SyncedToEntraDSUser', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'SyncedToEntraDSGroup', '', false);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AddEntraDSGroupMember', '', true);
- PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'SyncEntraDSUsers', '', true);
+ PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'EntraDSFor', '', false);
+ PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'ManageEntraDSSync', '', true);
+ PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'ManageEntraDSSyncFilter', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZRoleEligible', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZRoleApprover', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZAuthenticatesTo', '', true);
diff --git a/packages/csharp/graphschema/PropertyNames.cs b/packages/csharp/graphschema/PropertyNames.cs
index 4a976c546401..73ac845ccd28 100644
--- a/packages/csharp/graphschema/PropertyNames.cs
+++ b/packages/csharp/graphschema/PropertyNames.cs
@@ -1,18 +1,18 @@
/*
Copyright 2026 Specter Ops, Inc.
-
+
Licensed under the Apache License, Version 2.0
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
-
+
http://www.apache.org/licenses/LICENSE-2.0
-
+
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-
+
SPDX-License-Identifier: Apache-2.0
*/
//
diff --git a/packages/cue/bh/azure/azure.cue b/packages/cue/bh/azure/azure.cue
index 079c216fdde5..bad6919c6a51 100644
--- a/packages/cue/bh/azure/azure.cue
+++ b/packages/cue/bh/azure/azure.cue
@@ -353,11 +353,11 @@ DomainConfigurationType: types.#StringEnum & {
representation: "domainconfigurationtype"
}
-FilteredSync: types.#StringEnum & {
- symbol: "FilteredSync"
+FilteredSyncEnabled: types.#StringEnum & {
+ symbol: "FilteredSyncEnabled"
schema: "azure"
- name: "Filtered Sync"
- representation: "filteredsync"
+ name: "Filtered Sync Enabled"
+ representation: "filteredsyncenabled"
}
SyncScope: types.#StringEnum & {
@@ -374,88 +374,88 @@ SyncApplicationID: types.#StringEnum & {
representation: "syncapplicationid"
}
-NTLMV1: types.#StringEnum & {
- symbol: "NTLMV1"
+NTLMV1Enabled: types.#StringEnum & {
+ symbol: "NTLMV1Enabled"
schema: "azure"
- name: "NTLM V1"
- representation: "ntlmv1"
+ name: "NTLM V1 Enabled"
+ representation: "ntlmv1enabled"
}
-TLSV1: types.#StringEnum & {
- symbol: "TLSV1"
+TLSV1Enabled: types.#StringEnum & {
+ symbol: "TLSV1Enabled"
schema: "azure"
- name: "TLS V1"
- representation: "tlsv1"
+ name: "TLS V1 Enabled"
+ representation: "tlsv1enabled"
}
-SyncNTLMPasswords: types.#StringEnum & {
- symbol: "SyncNTLMPasswords"
+SyncNTLMPasswordsEnabled: types.#StringEnum & {
+ symbol: "SyncNTLMPasswordsEnabled"
schema: "azure"
- name: "Sync NTLM Passwords"
- representation: "syncntlmpasswords"
+ name: "Sync NTLM Passwords Enabled"
+ representation: "syncntlmpasswordsenabled"
}
-SyncKerberosPasswords: types.#StringEnum & {
- symbol: "SyncKerberosPasswords"
+SyncKerberosPasswordsEnabled: types.#StringEnum & {
+ symbol: "SyncKerberosPasswordsEnabled"
schema: "azure"
- name: "Sync Kerberos Passwords"
- representation: "synckerberospasswords"
+ name: "Sync Kerberos Passwords Enabled"
+ representation: "synckerberospasswordsenabled"
}
-SyncOnPremPasswords: types.#StringEnum & {
- symbol: "SyncOnPremPasswords"
+SyncOnPremPasswordsEnabled: types.#StringEnum & {
+ symbol: "SyncOnPremPasswordsEnabled"
schema: "azure"
- name: "Sync On-Premises Passwords"
- representation: "synconprempasswords"
+ name: "Sync On-Premises Passwords Enabled"
+ representation: "synconprempasswordsenabled"
}
-KerberosRC4Encryption: types.#StringEnum & {
- symbol: "KerberosRC4Encryption"
+KerberosRC4EncryptionEnabled: types.#StringEnum & {
+ symbol: "KerberosRC4EncryptionEnabled"
schema: "azure"
- name: "Kerberos RC4 Encryption"
- representation: "kerberosrc4encryption"
+ name: "Kerberos RC4 Encryption Enabled"
+ representation: "kerberosrc4encryptionenabled"
}
-KerberosArmoring: types.#StringEnum & {
- symbol: "KerberosArmoring"
+KerberosArmoringEnabled: types.#StringEnum & {
+ symbol: "KerberosArmoringEnabled"
schema: "azure"
- name: "Kerberos Armoring"
- representation: "kerberosarmoring"
+ name: "Kerberos Armoring Enabled"
+ representation: "kerberosarmoringenabled"
}
-LDAPSigning: types.#StringEnum & {
- symbol: "LDAPSigning"
+LDAPSigningEnabled: types.#StringEnum & {
+ symbol: "LDAPSigningEnabled"
schema: "azure"
- name: "LDAP Signing"
- representation: "ldapsigning"
+ name: "LDAP Signing Enabled"
+ representation: "ldapsigningenabled"
}
-ChannelBinding: types.#StringEnum & {
- symbol: "ChannelBinding"
+ChannelBindingEnabled: types.#StringEnum & {
+ symbol: "ChannelBindingEnabled"
schema: "azure"
- name: "Channel Binding"
- representation: "channelbinding"
+ name: "Channel Binding Enabled"
+ representation: "channelbindingenabled"
}
-SyncOnPremSAMAccountName: types.#StringEnum & {
- symbol: "SyncOnPremSAMAccountName"
+SyncOnPremSAMAccountNameEnabled: types.#StringEnum & {
+ symbol: "SyncOnPremSAMAccountNameEnabled"
schema: "azure"
- name: "Sync On-Premises SAM Account Name"
- representation: "synconpremsamaccountname"
+ name: "Sync On-Premises SAM Account Name Enabled"
+ representation: "synconpremsamaccountnameenabled"
}
-LDAPS: types.#StringEnum & {
- symbol: "LDAPS"
+LDAPSEnabled: types.#StringEnum & {
+ symbol: "LDAPSEnabled"
schema: "azure"
- name: "Secure LDAP"
- representation: "ldaps"
+ name: "Secure LDAP Enabled"
+ representation: "ldapsenabled"
}
-LDAPSExternalAccess: types.#StringEnum & {
- symbol: "LDAPSExternalAccess"
+LDAPSExternalAccessEnabled: types.#StringEnum & {
+ symbol: "LDAPSExternalAccessEnabled"
schema: "azure"
- name: "Secure LDAP External Access"
- representation: "ldapsexternalaccess"
+ name: "Secure LDAP External Access Enabled"
+ representation: "ldapsexternalaccessenabled"
}
Properties: [
@@ -505,21 +505,21 @@ Properties: [
FederatedIdentityCredentialAppID,
DomainName,
DomainConfigurationType,
- FilteredSync,
+ FilteredSyncEnabled,
SyncScope,
SyncApplicationID,
- NTLMV1,
- TLSV1,
- SyncNTLMPasswords,
- SyncKerberosPasswords,
- SyncOnPremPasswords,
- KerberosRC4Encryption,
- KerberosArmoring,
- LDAPSigning,
- ChannelBinding,
- SyncOnPremSAMAccountName,
- LDAPS,
- LDAPSExternalAccess
+ NTLMV1Enabled,
+ TLSV1Enabled,
+ SyncNTLMPasswordsEnabled,
+ SyncKerberosPasswordsEnabled,
+ SyncOnPremPasswordsEnabled,
+ KerberosRC4EncryptionEnabled,
+ KerberosArmoringEnabled,
+ LDAPSigningEnabled,
+ ChannelBindingEnabled,
+ SyncOnPremSAMAccountNameEnabled,
+ LDAPSEnabled,
+ LDAPSExternalAccessEnabled
]
// Kinds
@@ -806,6 +806,12 @@ EntraDSContributor: types.#Kind & {
representation: "AZEntraDSContributor"
}
+ManageEntraDS: types.#Kind & {
+ symbol: "ManageEntraDS"
+ schema: "azure"
+ representation: "AZManageEntraDS"
+}
+
GetCertificates: types.#Kind & {
symbol: "GetCertificates"
schema: "azure"
@@ -985,10 +991,22 @@ AddEntraDSGroupMember: types.#Kind & {
representation: "AddEntraDSGroupMember"
}
-SyncEntraDSUsers: types.#Kind & {
- symbol: "SyncEntraDSUsers"
+EntraDSFor: types.#Kind & {
+ symbol: "EntraDSFor"
+ schema: "azure"
+ representation: "EntraDSFor"
+}
+
+ManageEntraDSSync: types.#Kind & {
+ symbol: "ManageEntraDSSync"
+ schema: "azure"
+ representation: "ManageEntraDSSync"
+}
+
+ManageEntraDSSyncFilter: types.#Kind & {
+ symbol: "ManageEntraDSSyncFilter"
schema: "azure"
- representation: "SyncEntraDSUsers"
+ representation: "ManageEntraDSSyncFilter"
}
AZRoleEligible: types.#Kind & {
@@ -1014,6 +1032,7 @@ RelationshipKinds: [
Contains,
Contributor,
EntraDSContributor,
+ ManageEntraDS,
GetCertificates,
GetKeys,
GetSecrets,
@@ -1061,7 +1080,9 @@ RelationshipKinds: [
SyncedToEntraDSUser,
SyncedToEntraDSGroup,
AddEntraDSGroupMember,
- SyncEntraDSUsers,
+ EntraDSFor,
+ ManageEntraDSSync,
+ ManageEntraDSSyncFilter,
AZRoleEligible,
AZRoleApprover,
AZAuthenticatesTo
@@ -1088,7 +1109,7 @@ AbusableAppRoleRelationshipKinds: [
ControlRelationshipKinds: [
AvereContributor,
Contributor,
- EntraDSContributor,
+ ManageEntraDS,
Owner,
VMContributor,
AutomationContributor,
@@ -1131,7 +1152,7 @@ ExecutionPrivilegeKinds: [
InboundOutboundRelationshipKinds: [
AvereContributor,
Contributor,
- EntraDSContributor,
+ ManageEntraDS,
GetCertificates,
GetKeys,
GetSecrets,
@@ -1170,7 +1191,8 @@ InboundOutboundRelationshipKinds: [
SyncedToEntraUser,
SyncedToEntraDSUser,
AddEntraDSGroupMember,
- SyncEntraDSUsers,
+ ManageEntraDSSync,
+ ManageEntraDSSyncFilter,
AZRoleEligible,
AZRoleApprover,
Contains,
@@ -1179,12 +1201,21 @@ InboundOutboundRelationshipKinds: [
PathfindingRelationships: list.Concat([InboundOutboundRelationshipKinds])
+EdgeCompositionRelationships: [
+ ManageEntraDS,
+ AddEntraDSGroupMember,
+ ManageEntraDSSync,
+]
+
PostProcessedRelationships: [
ExecuteCommand,
+ ManageEntraDS,
SyncedToEntraUser,
SyncedToEntraDSUser,
SyncedToEntraDSGroup,
AddEntraDSGroupMember,
- SyncEntraDSUsers,
+ EntraDSFor,
+ ManageEntraDSSync,
+ ManageEntraDSSyncFilter,
AZRoleApprover,
]
diff --git a/packages/cue/bh/bh.cue b/packages/cue/bh/bh.cue
index ada308374fd5..296cd2d73f4e 100644
--- a/packages/cue/bh/bh.cue
+++ b/packages/cue/bh/bh.cue
@@ -42,6 +42,7 @@ import (
ExecutionPrivilegeKinds: [...types.#Kind]
PathfindingRelationships: [...types.#Kind]
InboundOutboundRelationshipKinds: [...types.#Kind]
+ EdgeCompositionRelationships: [...types.#Kind]
PostProcessedRelationships: [...types.#Kind]
}
@@ -78,6 +79,7 @@ Azure: #Azure & {
ExecutionPrivilegeKinds: azure.ExecutionPrivilegeKinds
PathfindingRelationships: azure.PathfindingRelationships
InboundOutboundRelationshipKinds: azure.InboundOutboundRelationshipKinds
+ EdgeCompositionRelationships: azure.EdgeCompositionRelationships
PostProcessedRelationships: azure.PostProcessedRelationships
}
diff --git a/packages/go/analysis/ad/ad.go b/packages/go/analysis/ad/ad.go
index ae40256161fa..4256a51720b6 100644
--- a/packages/go/analysis/ad/ad.go
+++ b/packages/go/analysis/ad/ad.go
@@ -29,7 +29,6 @@ import (
"github.com/specterops/bloodhound/packages/go/bhlog/attr"
"github.com/specterops/bloodhound/packages/go/bhlog/measure"
"github.com/specterops/bloodhound/packages/go/graphschema/ad"
- "github.com/specterops/bloodhound/packages/go/graphschema/azure"
"github.com/specterops/bloodhound/packages/go/graphschema/common"
"github.com/specterops/dawgs/cardinality"
"github.com/specterops/dawgs/graph"
@@ -725,8 +724,6 @@ func GetEdgeCompositionPath(ctx context.Context, db graph.Database, edge *graph.
pathSet, err = GetCoerceAndRelayNTLMtoADCSEdgeComposition(ctx, db, edge)
case ad.CoerceAndRelayNTLMToSMB:
pathSet, err = GetCoerceAndRelayNTLMtoSMBEdgeComposition(ctx, db, edge)
- case azure.AddEntraDSGroupMember:
- pathSet, err = GetAddEntraDSGroupMemberEdgeComposition(ctx, db, edge)
}
return err
}); err != nil {
diff --git a/packages/go/analysis/ad/entra_ds.go b/packages/go/analysis/ad/entra_ds.go
deleted file mode 100644
index 5406a84704ce..000000000000
--- a/packages/go/analysis/ad/entra_ds.go
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright 2026 Specter Ops, Inc.
-//
-// Licensed under the Apache License, Version 2.0
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-package ad
-
-import (
- "context"
-
- "github.com/specterops/bloodhound/packages/go/graphschema/azure"
- "github.com/specterops/dawgs/graph"
- "github.com/specterops/dawgs/ops"
- "github.com/specterops/dawgs/query"
-)
-
-// GetAddEntraDSGroupMemberEdgeComposition reconstructs the paths that compose an AddEntraDSGroupMember edge. The
-// edge's start node is the AZUser and its end node is the on-prem Group whose membership it can modify. The composition is:
-//
-// p1 = (azUser)-[:SyncedToEntraDSUser]->(:User)
-// p2 = (azUser)-[:AZOwns|AZAddMembers]->(azGroup:AZGroup)
-// p3 = (azGroup)-[:SyncedToEntraDSGroup]->(targetGroup)
-func GetAddEntraDSGroupMemberEdgeComposition(ctx context.Context, db graph.Database, edge *graph.Relationship) (graph.PathSet, error) {
- finalPaths := graph.NewPathSet()
-
- if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error {
- azUser, targetGroup, err := ops.FetchRelationshipNodes(tx, edge)
- if err != nil {
- return err
- }
-
- // p1: the SyncedToEntraDSUser edge originating at the AZUser
- syncedUserPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
- query.Equals(query.StartID(), azUser.ID),
- query.Kind(query.Relationship(), azure.SyncedToEntraDSUser),
- )))
- if err != nil {
- return err
- }
-
- // p3: the SyncedToEntraDSGroup edges terminating at the target on-prem Group; the start node of each is an AZGroup
- syncedGroupPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
- query.Equals(query.EndID(), targetGroup.ID),
- query.Kind(query.Relationship(), azure.SyncedToEntraDSGroup),
- )))
- if err != nil {
- return err
- }
-
- // Without both the AZUser being synced and the target group being synced from an AZGroup there is no valid composition
- if syncedUserPaths.Len() == 0 || syncedGroupPaths.Len() == 0 {
- return nil
- }
-
- for _, syncedGroupPath := range syncedGroupPaths {
- azGroup := syncedGroupPath.Root()
-
- // p2: the AZOwns / AZAddMembers control edge(s) from the AZUser to this AZGroup
- controlPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
- query.Equals(query.StartID(), azUser.ID),
- query.Equals(query.EndID(), azGroup.ID),
- query.KindIn(query.Relationship(), azure.AddMembers, azure.Owns),
- )))
- if err != nil {
- return err
- } else if controlPaths.Len() == 0 {
- continue
- }
-
- finalPaths.AddPathSet(controlPaths)
- finalPaths.AddPath(syncedGroupPath)
- }
-
- // Only include the user sync path (p1) if at least one complete composition was found
- if finalPaths.Len() > 0 {
- finalPaths.AddPathSet(syncedUserPaths)
- }
-
- return nil
- }); err != nil {
- return graph.NewPathSet(), err
- }
-
- return finalPaths, nil
-}
diff --git a/packages/go/analysis/azure/azure_integration_test.go b/packages/go/analysis/azure/azure_integration_test.go
index efe3c323cbbe..9a215368729d 100644
--- a/packages/go/analysis/azure/azure_integration_test.go
+++ b/packages/go/analysis/azure/azure_integration_test.go
@@ -25,6 +25,7 @@ import (
"github.com/specterops/bloodhound/cmd/api/src/test/integration"
"github.com/specterops/bloodhound/packages/go/analysis/azure"
+ "github.com/specterops/bloodhound/packages/go/analysis/edgecomposition"
schema "github.com/specterops/bloodhound/packages/go/graphschema"
graphAzure "github.com/specterops/bloodhound/packages/go/graphschema/azure"
"github.com/specterops/bloodhound/packages/go/graphschema/common"
@@ -1168,3 +1169,107 @@ func TestListEntityDescendents_NestedManagementGroupToSubscription(t *testing.T)
require.Equal(t, 1, len(nodes), "expected nested subscription to be returned as a descendent of mgRoot")
require.Contains(t, nodes.IDs(), nestedSubNode.ID)
}
+
+func TestManageEntraDSRequiresARMAndBothDirectoryRoles(t *testing.T) {
+ t.Parallel()
+
+ suite := setupIntegrationTestSuite(t)
+ defer teardownIntegrationTestSuite(t, &suite)
+
+ tenantID := integration.RandomObjectID(t)
+ tenant := NewAzureTenant(t, &suite, tenantID)
+ appAdminRole := NewAzureRole(t, &suite, "Application Administrator", integration.RandomObjectID(t), graphAzure.ApplicationAdministratorRole, tenantID)
+ groupsAdminRole := NewAzureRole(t, &suite, "Groups Administrator", integration.RandomObjectID(t), graphAzure.GroupsAdministratorRole, tenantID)
+ subscription := NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
+ common.Name: "Subscription",
+ common.ObjectID: integration.RandomObjectID(t),
+ graphAzure.TenantID: tenantID,
+ }), graphAzure.Entity, graphAzure.Subscription)
+ resourceGroup := NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
+ common.Name: "Resource Group",
+ common.ObjectID: integration.RandomObjectID(t),
+ graphAzure.TenantID: tenantID,
+ }), graphAzure.Entity, graphAzure.ResourceGroup)
+ domainService := NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
+ common.Name: "Managed Domain",
+ common.ObjectID: integration.RandomObjectID(t),
+ graphAzure.TenantID: tenantID,
+ }), graphAzure.Entity, graphAzure.EntraDS)
+ armGroup := NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
+ common.Name: "Inherited ARM Contributors",
+ common.ObjectID: integration.RandomObjectID(t),
+ graphAzure.TenantID: tenantID,
+ }), graphAzure.Entity, graphAzure.Group)
+ qualifiedUser := NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
+ common.Name: "Qualified User",
+ common.ObjectID: integration.RandomObjectID(t),
+ graphAzure.TenantID: tenantID,
+ }), graphAzure.Entity, graphAzure.User)
+ appOnlyUser := NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
+ common.Name: "Application Administrator Only",
+ common.ObjectID: integration.RandomObjectID(t),
+ graphAzure.TenantID: tenantID,
+ }), graphAzure.Entity, graphAzure.User)
+ domainServicesContributor := NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
+ common.Name: "Direct Domain Services Contributor",
+ common.ObjectID: integration.RandomObjectID(t),
+ graphAzure.TenantID: tenantID,
+ }), graphAzure.Entity, graphAzure.User)
+
+ for _, principal := range []*graph.Node{appAdminRole, groupsAdminRole, armGroup, qualifiedUser, appOnlyUser, domainServicesContributor} {
+ NewRelationship(t, &suite, tenant, principal, graphAzure.Contains)
+ }
+ NewRelationship(t, &suite, tenant, subscription, graphAzure.Contains)
+ NewRelationship(t, &suite, subscription, resourceGroup, graphAzure.Contains)
+ NewRelationship(t, &suite, resourceGroup, domainService, graphAzure.Contains)
+ NewRelationship(t, &suite, armGroup, resourceGroup, graphAzure.Contributor)
+ NewRelationship(t, &suite, qualifiedUser, armGroup, graphAzure.MemberOf)
+ NewRelationship(t, &suite, appOnlyUser, armGroup, graphAzure.MemberOf)
+ NewRelationship(t, &suite, domainServicesContributor, domainService, graphAzure.EntraDSContributor)
+
+ for _, principal := range []*graph.Node{qualifiedUser, appOnlyUser, domainServicesContributor} {
+ NewRelationship(t, &suite, principal, appAdminRole, graphAzure.HasRole)
+ }
+ for _, principal := range []*graph.Node{qualifiedUser, domainServicesContributor} {
+ NewRelationship(t, &suite, principal, groupsAdminRole, graphAzure.HasRole)
+ }
+
+ _, err := azure.ManageEntraDS(context.Background(), suite.GraphDB)
+ require.NoError(t, err)
+
+ 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
+ })
+ require.NoError(t, err)
+
+ for _, edge := range manageEdges {
+ composition, err := edgecomposition.GetEdgeCompositionPath(context.Background(), suite.GraphDB, edge)
+ require.NoError(t, err)
+ nodes := composition.AllNodes()
+ assert.True(t, nodes.Contains(tenant))
+ assert.True(t, nodes.Contains(appAdminRole))
+ assert.True(t, nodes.Contains(groupsAdminRole))
+ assert.True(t, nodes.Contains(domainService))
+ assert.False(t, nodes.Contains(appOnlyUser))
+
+ if edge.StartID == qualifiedUser.ID {
+ assert.True(t, nodes.Contains(qualifiedUser))
+ assert.True(t, nodes.Contains(armGroup))
+ assert.True(t, nodes.Contains(resourceGroup))
+ } else {
+ assert.True(t, nodes.Contains(domainServicesContributor))
+ assert.False(t, nodes.Contains(armGroup))
+ }
+ }
+}
diff --git a/packages/go/analysis/azure/entra_domain_services.go b/packages/go/analysis/azure/entra_domain_services.go
new file mode 100644
index 000000000000..c7f370f55cec
--- /dev/null
+++ b/packages/go/analysis/azure/entra_domain_services.go
@@ -0,0 +1,363 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package azure
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+
+ "github.com/specterops/bloodhound/packages/go/analysis/post"
+ "github.com/specterops/bloodhound/packages/go/bhlog/attr"
+ "github.com/specterops/bloodhound/packages/go/bhlog/measure"
+ azschema "github.com/specterops/bloodhound/packages/go/graphschema/azure"
+ "github.com/specterops/bloodhound/packages/go/graphschema/common"
+ "github.com/specterops/dawgs/graph"
+ "github.com/specterops/dawgs/ops"
+ "github.com/specterops/dawgs/query"
+ "github.com/specterops/dawgs/util/channels"
+)
+
+func GetManageEntraDSEdgeComposition(ctx context.Context, db graph.Database, edge *graph.Relationship) (graph.PathSet, error) {
+ finalPaths := graph.NewPathSet()
+
+ if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error {
+ source, domainService, err := ops.FetchRelationshipNodes(tx, edge)
+ if err != nil {
+ return err
+ }
+
+ armPaths, err := getManageEntraDSARMComposition(tx, source, domainService)
+ if err != nil {
+ return err
+ } else if armPaths.Len() == 0 {
+ return nil
+ }
+
+ tenant, err := getEntraDSTenant(tx, domainService)
+ if err != nil {
+ return err
+ } else if tenant == nil {
+ return nil
+ }
+
+ applicationAdministratorPaths, err := getManageEntraDSRoleComposition(tx, tenant, source, azschema.ApplicationAdministratorRole)
+ if err != nil {
+ return err
+ } else if applicationAdministratorPaths.Len() == 0 {
+ return nil
+ }
+
+ groupsAdministratorPaths, err := getManageEntraDSRoleComposition(tx, tenant, source, azschema.GroupsAdministratorRole)
+ if err != nil {
+ return err
+ } else if groupsAdministratorPaths.Len() == 0 {
+ return nil
+ }
+
+ finalPaths.AddPathSet(armPaths)
+ finalPaths.AddPathSet(applicationAdministratorPaths)
+ finalPaths.AddPathSet(groupsAdministratorPaths)
+ return nil
+ }); err != nil {
+ return graph.NewPathSet(), err
+ }
+
+ return finalPaths, nil
+}
+
+func getManageEntraDSARMComposition(tx graph.Transaction, source, domainService *graph.Node) (graph.PathSet, error) {
+ var (
+ finalPaths = graph.NewPathSet()
+ controlTargets = graph.NewNodeSet(domainService)
+ )
+
+ ancestorPaths, err := ops.TraversePaths(tx, ops.TraversalPlan{
+ Root: domainService,
+ Direction: graph.DirectionInbound,
+ BranchQuery: func() graph.Criteria {
+ return query.Kind(query.Relationship(), azschema.Contains)
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ controlTargets.AddSet(ancestorPaths.AllNodes())
+
+ controlEdges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.And(
+ query.InIDs(query.EndID(), controlTargets.IDs()...),
+ query.KindIn(query.Relationship(), azschema.Contributor, azschema.EntraDSContributor),
+ query.KindIn(query.Start(), azschema.User, azschema.Group, azschema.ServicePrincipal),
+ )
+ }))
+ if err != nil {
+ return nil, err
+ }
+
+ for _, controlEdge := range controlEdges {
+ controller, scope, err := ops.FetchRelationshipNodes(tx, controlEdge)
+ if err != nil {
+ return nil, err
+ }
+
+ membershipPaths := graph.NewPathSet()
+ if controller.ID != source.ID {
+ membershipPaths, err = ops.TraversePaths(tx, ops.TraversalPlan{
+ Root: controller,
+ Direction: graph.DirectionInbound,
+ BranchQuery: func() graph.Criteria {
+ return query.Kind(query.Relationship(), azschema.MemberOf)
+ },
+ ExpansionFilter: func(segment *graph.PathSegment) bool {
+ return segment.Node.ID != source.ID
+ },
+ PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
+ return segment.Node.ID == source.ID
+ },
+ })
+ if err != nil {
+ return nil, err
+ } else if membershipPaths.Len() == 0 {
+ continue
+ }
+ }
+
+ containmentPaths := graph.NewPathSet()
+ if scope.ID != domainService.ID {
+ containmentPaths, err = ops.TraversePaths(tx, ops.TraversalPlan{
+ Root: domainService,
+ Direction: graph.DirectionInbound,
+ BranchQuery: func() graph.Criteria {
+ return query.Kind(query.Relationship(), azschema.Contains)
+ },
+ ExpansionFilter: func(segment *graph.PathSegment) bool {
+ return segment.Node.ID != scope.ID
+ },
+ PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
+ return segment.Node.ID == scope.ID
+ },
+ })
+ if err != nil {
+ return nil, err
+ } else if containmentPaths.Len() == 0 {
+ continue
+ }
+ }
+
+ controlPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
+ query.Equals(query.StartID(), controller.ID),
+ query.Equals(query.EndID(), scope.ID),
+ query.Kind(query.Relationship(), controlEdge.Kind),
+ )))
+ if err != nil {
+ return nil, err
+ }
+
+ finalPaths.AddPathSet(membershipPaths)
+ finalPaths.AddPathSet(controlPaths)
+ finalPaths.AddPathSet(containmentPaths)
+ }
+
+ return finalPaths, nil
+}
+
+func getEntraDSTenant(tx graph.Transaction, domainService *graph.Node) (*graph.Node, error) {
+ tenantID, err := domainService.Properties.Get(azschema.TenantID.String()).String()
+ if err != nil {
+ return nil, err
+ }
+
+ tenants, err := ops.FetchNodes(tx.Nodes().Filter(query.Kind(query.Node(), azschema.Tenant)))
+ if err != nil {
+ return nil, err
+ }
+ for _, tenant := range tenants {
+ if objectID, err := tenant.Properties.Get(common.ObjectID.String()).String(); err == nil && strings.EqualFold(strings.TrimSpace(objectID), strings.TrimSpace(tenantID)) {
+ return tenant, nil
+ }
+ }
+
+ return nil, nil
+}
+
+func getManageEntraDSRoleComposition(tx graph.Transaction, tenant, source *graph.Node, roleTemplateID string) (graph.PathSet, error) {
+ finalPaths := graph.NewPathSet()
+ roles, err := TenantRoles(tx, tenant, roleTemplateID)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, role := range roles {
+ rolePaths, err := ops.TraversePaths(tx, ops.TraversalPlan{
+ Root: role,
+ Direction: graph.DirectionInbound,
+ BranchQuery: func() graph.Criteria {
+ return query.KindIn(query.Relationship(), azschema.MemberOf, azschema.HasRole)
+ },
+ DescentFilter: roleDescentFilter,
+ ExpansionFilter: func(segment *graph.PathSegment) bool {
+ return segment.Node.ID != source.ID
+ },
+ PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
+ return segment.Node.ID == source.ID
+ },
+ })
+ if err != nil {
+ return nil, err
+ } else if rolePaths.Len() == 0 {
+ continue
+ }
+
+ tenantPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
+ query.Equals(query.StartID(), tenant.ID),
+ query.Equals(query.EndID(), role.ID),
+ query.Kind(query.Relationship(), azschema.Contains),
+ )))
+ if err != nil {
+ return nil, err
+ } else if tenantPaths.Len() == 0 {
+ continue
+ }
+
+ finalPaths.AddPathSet(rolePaths)
+ finalPaths.AddPathSet(tenantPaths)
+ }
+
+ return finalPaths, nil
+}
+
+// ManageEntraDS creates the traversable AZManageEntraDS relationship only when the same
+// effective principal has all permissions observed in validation: Contributor or Domain Services
+// Contributor over the managed-domain resource, Application Administrator, and Groups Administrator.
+// The source ARM assignment may be direct, inherited through AZContains, or effective through nested
+// AZMemberOf membership.
+func ManageEntraDS(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) {
+ defer measure.ContextLogAndMeasure(
+ ctx,
+ slog.LevelInfo,
+ "Post-processing Entra Domain Services contributors",
+ attr.Namespace("analysis"),
+ attr.Function("ManageEntraDS"),
+ attr.Scope("process"),
+ )()
+
+ tenants, err := FetchTenants(ctx, db)
+ if err != nil {
+ return &post.AtomicPostProcessingStats{}, err
+ }
+
+ operation := post.NewPostRelationshipOperation(ctx, db, "AZManageEntraDS Post Processing")
+ for _, tenant := range tenants {
+ roleAssignments, err := FetchTenantRoleAssignments(ctx, db, tenant)
+ if err != nil {
+ _ = operation.Done()
+ return &operation.Stats, err
+ }
+
+ qualifiedPrincipals := roleAssignments.PrincipalsWithRole(azschema.ApplicationAdministratorRole)
+ qualifiedPrincipals.And(roleAssignments.PrincipalsWithRole(azschema.GroupsAdministratorRole))
+ if qualifiedPrincipals.Cardinality() == 0 {
+ continue
+ }
+
+ tenant := tenant
+ if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error {
+ domainServices, err := FetchDescendentKindByTenantID(tx, tenant, azschema.EntraDS)
+ if err != nil {
+ return err
+ }
+
+ for _, domainService := range domainServices {
+ controllers, err := effectiveEntraDSResourceControllers(tx, domainService)
+ if err != nil {
+ return err
+ }
+
+ for _, controller := range controllers {
+ if qualifiedPrincipals.Contains(controller.ID.Uint64()) {
+ if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{
+ FromID: controller.ID,
+ ToID: domainService.ID,
+ Kind: azschema.ManageEntraDS,
+ }) {
+ return nil
+ }
+ }
+ }
+ }
+
+ return nil
+ }); err != nil {
+ _ = operation.Done()
+ return &operation.Stats, err
+ }
+ }
+
+ return &operation.Stats, operation.Done()
+}
+
+func effectiveEntraDSResourceControllers(tx graph.Transaction, domainService *graph.Node) (graph.NodeSet, error) {
+ controlTargets := graph.NewNodeSet(domainService)
+ if paths, err := ops.TraversePaths(tx, ops.TraversalPlan{
+ Root: domainService,
+ Direction: graph.DirectionInbound,
+ BranchQuery: func() graph.Criteria {
+ return query.Kind(query.Relationship(), azschema.Contains)
+ },
+ }); err != nil {
+ return nil, err
+ } else {
+ controlTargets.AddSet(paths.AllNodes())
+ }
+
+ controllers, err := ops.FetchStartNodes(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.And(
+ query.InIDs(query.EndID(), controlTargets.IDs()...),
+ query.KindIn(query.Relationship(), azschema.Contributor, azschema.EntraDSContributor),
+ query.KindIn(query.Start(), azschema.User, azschema.Group, azschema.ServicePrincipal),
+ )
+ }))
+ if err != nil {
+ return nil, err
+ }
+
+ effectiveControllers := graph.NewNodeSet()
+ for _, controller := range controllers {
+ effectiveControllers.Add(controller)
+ if !controller.Kinds.ContainsOneOf(azschema.Group) {
+ continue
+ }
+
+ if paths, err := ops.TraversePaths(tx, ops.TraversalPlan{
+ Root: controller,
+ Direction: graph.DirectionInbound,
+ BranchQuery: func() graph.Criteria {
+ return query.Kind(query.Relationship(), azschema.MemberOf)
+ },
+ PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
+ return segment.Node.Kinds.ContainsOneOf(azschema.User, azschema.Group, azschema.ServicePrincipal)
+ },
+ }); err != nil {
+ return nil, err
+ } else {
+ effectiveControllers.AddSet(paths.AllNodes())
+ }
+ }
+
+ return effectiveControllers, nil
+}
diff --git a/packages/go/analysis/azure/post.go b/packages/go/analysis/azure/post.go
index 241d47322c82..655b43938232 100644
--- a/packages/go/analysis/azure/post.go
+++ b/packages/go/analysis/azure/post.go
@@ -1103,6 +1103,8 @@ func Post(ctx context.Context, db graph.Database, useRawObjectIDs bool) (*post.A
return &aggregateStats, err
} else if addOwnerStats, err := CreateAZAddOwnerEdge(ctx, db); err != nil {
return &aggregateStats, err
+ } else if entraDSContributorStats, err := ManageEntraDS(ctx, db); err != nil {
+ return &aggregateStats, err
} else if hybridStats, err := hybrid.PostHybrid(ctx, db); err != nil {
return &aggregateStats, err
} else if pimRolesStats, err := CreateAZRoleApproverEdge(ctx, db); err != nil {
@@ -1110,6 +1112,7 @@ func Post(ctx context.Context, db graph.Database, useRawObjectIDs bool) (*post.A
} else {
aggregateStats.Merge(executeCommandStats)
aggregateStats.Merge(addOwnerStats)
+ aggregateStats.Merge(entraDSContributorStats)
aggregateStats.Merge(hybridStats)
aggregateStats.Merge(pimRolesStats)
diff --git a/packages/go/analysis/edgecomposition/edgecomposition.go b/packages/go/analysis/edgecomposition/edgecomposition.go
new file mode 100644
index 000000000000..2c5b33ed11e1
--- /dev/null
+++ b/packages/go/analysis/edgecomposition/edgecomposition.go
@@ -0,0 +1,44 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package edgecomposition
+
+import (
+ "context"
+
+ "github.com/specterops/bloodhound/packages/go/analysis/ad"
+ analysisAzure "github.com/specterops/bloodhound/packages/go/analysis/azure"
+ "github.com/specterops/bloodhound/packages/go/analysis/hybrid"
+ "github.com/specterops/bloodhound/packages/go/graphschema/azure"
+ "github.com/specterops/dawgs/graph"
+)
+
+func GetEdgeCompositionPath(ctx context.Context, db graph.Database, edge *graph.Relationship) (graph.PathSet, error) {
+ if edge == nil {
+ return ad.GetEdgeCompositionPath(ctx, db, edge)
+ }
+
+ switch edge.Kind {
+ case azure.ManageEntraDS:
+ return analysisAzure.GetManageEntraDSEdgeComposition(ctx, db, edge)
+ case azure.AddEntraDSGroupMember:
+ return hybrid.GetAddEntraDSGroupMemberEdgeComposition(ctx, db, edge)
+ case azure.ManageEntraDSSync:
+ return hybrid.GetManageEntraDSSyncEdgeComposition(ctx, db, edge)
+ default:
+ return ad.GetEdgeCompositionPath(ctx, db, edge)
+ }
+}
diff --git a/packages/go/analysis/hybrid/composition.go b/packages/go/analysis/hybrid/composition.go
new file mode 100644
index 000000000000..ae2a42c8b625
--- /dev/null
+++ b/packages/go/analysis/hybrid/composition.go
@@ -0,0 +1,172 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package hybrid
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ graphschemaAD "github.com/specterops/bloodhound/packages/go/graphschema/ad"
+ "github.com/specterops/bloodhound/packages/go/graphschema/azure"
+ "github.com/specterops/dawgs/graph"
+ "github.com/specterops/dawgs/ops"
+ "github.com/specterops/dawgs/query"
+)
+
+// GetAddEntraDSGroupMemberEdgeComposition reconstructs the paths that compose an AddEntraDSGroupMember edge. The
+// edge's start node is the AZUser and its end node is the AD Group whose membership it can modify. The composition is:
+//
+// p1 = (azUser)-[:SyncedToEntraDSUser]->(:User)
+// p2 = (azUser)-[:AZOwns|AZAddMembers]->(azGroup:AZGroup)
+// p3 = (azGroup)-[:SyncedToEntraDSGroup]->(targetGroup)
+func GetAddEntraDSGroupMemberEdgeComposition(ctx context.Context, db graph.Database, edge *graph.Relationship) (graph.PathSet, error) {
+ finalPaths := graph.NewPathSet()
+
+ if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error {
+ azUser, targetGroup, err := ops.FetchRelationshipNodes(tx, edge)
+ if err != nil {
+ return err
+ }
+
+ syncedUserPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
+ query.Equals(query.StartID(), azUser.ID),
+ query.Kind(query.Relationship(), azure.SyncedToEntraDSUser),
+ )))
+ if err != nil {
+ return err
+ }
+
+ syncedGroupPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
+ query.Equals(query.EndID(), targetGroup.ID),
+ query.Kind(query.Relationship(), azure.SyncedToEntraDSGroup),
+ )))
+ if err != nil {
+ return err
+ }
+
+ if syncedUserPaths.Len() == 0 || syncedGroupPaths.Len() == 0 {
+ return nil
+ }
+
+ for _, syncedGroupPath := range syncedGroupPaths {
+ azGroup := syncedGroupPath.Root()
+ controlPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
+ query.Equals(query.StartID(), azUser.ID),
+ query.Equals(query.EndID(), azGroup.ID),
+ query.KindIn(query.Relationship(), azure.AddMembers, azure.Owns),
+ )))
+ if err != nil {
+ return err
+ } else if controlPaths.Len() == 0 {
+ continue
+ }
+
+ finalPaths.AddPathSet(controlPaths)
+ finalPaths.AddPath(syncedGroupPath)
+ }
+
+ if finalPaths.Len() > 0 {
+ finalPaths.AddPathSet(syncedUserPaths)
+ }
+ return nil
+ }); err != nil {
+ return graph.NewPathSet(), err
+ }
+
+ return finalPaths, nil
+}
+
+// GetManageEntraDSSyncEdgeComposition reconstructs the relationships that compose a ManageEntraDSSync edge:
+//
+// p1 = (source)-[:AZManageEntraDS]->(domainService:AZEntraDS)
+// p2 = (domainService)-[:EntraDSFor]->(domain:Domain)
+// p3 = (domain)-[:Contains*1..]->(targetGroup:Group)
+func GetManageEntraDSSyncEdgeComposition(ctx context.Context, db graph.Database, edge *graph.Relationship) (graph.PathSet, error) {
+ finalPaths := graph.NewPathSet()
+
+ if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error {
+ source, targetGroup, err := ops.FetchRelationshipNodes(tx, edge)
+ if err != nil {
+ return err
+ }
+
+ targetDomainSID, err := targetGroup.Properties.Get(graphschemaAD.DomainSID.String()).String()
+ if errors.Is(err, graph.ErrPropertyNotFound) || strings.TrimSpace(targetDomainSID) == "" {
+ return nil
+ } else if err != nil {
+ return err
+ }
+
+ managementPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
+ query.Equals(query.StartID(), source.ID),
+ query.Kind(query.Relationship(), azure.ManageEntraDS),
+ )))
+ if err != nil {
+ return err
+ }
+
+ for _, managementPath := range managementPaths {
+ domainService := managementPath.Terminal()
+ correlationPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
+ query.Equals(query.StartID(), domainService.ID),
+ query.Kind(query.Relationship(), azure.EntraDSFor),
+ )))
+ if err != nil {
+ return err
+ }
+
+ for _, correlationPath := range correlationPaths {
+ domain := correlationPath.Terminal()
+ domainSID, err := domain.Properties.Get(graphschemaAD.DomainSID.String()).String()
+ if errors.Is(err, graph.ErrPropertyNotFound) {
+ continue
+ } else if err != nil {
+ return err
+ } else if !strings.EqualFold(strings.TrimSpace(domainSID), strings.TrimSpace(targetDomainSID)) {
+ 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 err != nil {
+ return err
+ } else if containmentPaths.Len() == 0 {
+ continue
+ }
+
+ finalPaths.AddPath(managementPath)
+ finalPaths.AddPath(correlationPath)
+ finalPaths.AddPathSet(containmentPaths)
+ }
+ }
+
+ return nil
+ }); err != nil {
+ return graph.NewPathSet(), err
+ }
+
+ return finalPaths, nil
+}
diff --git a/packages/go/analysis/hybrid/hybrid.go b/packages/go/analysis/hybrid/hybrid.go
index 1f940d9ba1ea..98fe95636133 100644
--- a/packages/go/analysis/hybrid/hybrid.go
+++ b/packages/go/analysis/hybrid/hybrid.go
@@ -39,7 +39,6 @@ const (
entraDSAdminGroupNamePrefix = "AAD DC ADMINISTRATORS@"
entraDSScopedSyncApplicationID = "2565BD9D-DA50-47D4-8B85-4C97F669DC36"
domainUsersObjectIDSuffix = "-513"
- entraDSFilteredSyncEnabled = "ENABLED"
entraDSSyncScopeAll = "ALL"
)
@@ -86,14 +85,16 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
// adObjIDMap is used as a reverse mapping of a list of Entra node ids indexed by the AD user objectids
adObjIDMap = make(map[string][]graph.ID, 1024)
// entraToADMap is the final mapping between an Entra user node id to an AD user node id
- entraToADMap = make(map[graph.ID]graph.ID, 1024)
- entraDSUserAADObjectIDMap = make(map[string][]graph.ID, 1024)
- entraDSGroupAADObjectIDMap = make(map[string][]graph.ID, 1024)
- entraDSAdminGroupTenantMap = make(map[graph.ID]string, 16)
- syncedToEntraDSUserEdgeMap = make(map[graph.ID][]graph.ID, 1024)
- syncedToEntraDSGroupEdgeMap = make(map[graph.ID][]graph.ID, 1024)
- addEntraDSGroupMemberEdgeMap = make(map[graph.ID][]graph.ID, 1024)
- syncEntraDSUsersEdgeMap = make(map[graph.ID][]graph.ID, 16)
+ entraToADMap = make(map[graph.ID]graph.ID, 1024)
+ entraDSUserAADObjectIDMap = make(map[string][]graph.ID, 1024)
+ entraDSGroupAADObjectIDMap = make(map[string][]graph.ID, 1024)
+ entraDSAdminGroupTenantMap = make(map[graph.ID]string, 16)
+ syncedToEntraDSUserEdgeMap = make(map[graph.ID][]graph.ID, 1024)
+ syncedToEntraDSGroupEdgeMap = make(map[graph.ID][]graph.ID, 1024)
+ addEntraDSGroupMemberEdgeMap = make(map[graph.ID][]graph.ID, 1024)
+ entraDSForEdgeMap = make(map[graph.ID][]graph.ID, 16)
+ manageEntraDSSyncEdgeMap = make(map[graph.ID][]graph.ID, 16)
+ manageEntraDSSyncFilterEdgeMap = make(map[graph.ID][]graph.ID, 16)
)
// Work on Entra users by their tenant association. Loop therefore through each Entra tenant
@@ -175,10 +176,10 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
return err
}
- // The managed domain controls the broad synchronization boundary. The Domain Controller Services service
- // principal can only add users through filtered group scope when the related managed domain is currently
- // configured for filtered synchronization across all users.
- if err := addSyncEntraDSUsersEdges(tx, adGroups, entraDSAdminGroupTenantMap, syncedToEntraDSGroupEdgeMap, syncEntraDSUsersEdgeMap); err != nil {
+ // A qualified AZManageEntraDS principal controls the broad synchronization boundary. The Domain Controller
+ // Services service principal can only add users through filtered group scope when the related managed domain is
+ // currently configured for filtered synchronization across all users.
+ if err := addManageEntraDSSyncEdges(tx, adGroups, entraDSAdminGroupTenantMap, syncedToEntraDSGroupEdgeMap, entraDSForEdgeMap, manageEntraDSSyncEdgeMap, manageEntraDSSyncFilterEdgeMap); err != nil {
return err
}
@@ -247,15 +248,41 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
}
}
- for sourceNode, domainUserGroups := range syncEntraDSUsersEdgeMap {
+ for domainService, domains := range entraDSForEdgeMap {
+ for _, domain := range domains {
+ if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{
+ FromID: domainService,
+ ToID: domain,
+ Kind: azure.EntraDSFor,
+ }) {
+ return nil
+ }
+ }
+ }
+
+ for sourceNode, domainUserGroups := range manageEntraDSSyncEdgeMap {
+ for _, domainUserGroup := range domainUserGroups {
+ manageEntraDSSyncRelationship := post.EnsureRelationshipJob{
+ FromID: sourceNode,
+ ToID: domainUserGroup,
+ Kind: azure.ManageEntraDSSync,
+ }
+
+ if !channels.Submit(ctx, outC, manageEntraDSSyncRelationship) {
+ return nil
+ }
+ }
+ }
+
+ for sourceNode, domainUserGroups := range manageEntraDSSyncFilterEdgeMap {
for _, domainUserGroup := range domainUserGroups {
- syncEntraDSUsersRelationship := post.EnsureRelationshipJob{
+ manageEntraDSSyncFilterRelationship := post.EnsureRelationshipJob{
FromID: sourceNode,
ToID: domainUserGroup,
- Kind: azure.SyncEntraDSUsers,
+ Kind: azure.ManageEntraDSSyncFilter,
}
- if !channels.Submit(ctx, outC, syncEntraDSUsersRelationship) {
+ if !channels.Submit(ctx, outC, manageEntraDSSyncFilterRelationship) {
return nil
}
}
@@ -314,40 +341,38 @@ func addEntraDSAdminGroupTenant(entraDSAdminGroupTenantMap map[graph.ID]string,
return nil
}
-// addSyncEntraDSUsersEdges computes the SyncEntraDSUsers edges. A synchronized AAD DC Administrators group identifies
-// each tenant's Entra Domain Services domain, and the domain SID identifies its Domain Users group. The managed domain
-// always receives an edge because control of the ARM resource can change the synchronization boundary. The known
-// Domain Controller Services service principal receives a narrower edge only when filtered synchronization is enabled
-// with sync scope All.
-func addSyncEntraDSUsersEdges(tx graph.Transaction, adGroups []*graph.Node, entraDSAdminGroupTenantMap map[graph.ID]string, syncedToEntraDSGroupEdgeMap, syncEntraDSUsersEdgeMap map[graph.ID][]graph.ID) error {
+// addManageEntraDSSyncEdges correlates each AZEntraDS resource to an AD Domain by normalized domain name and the
+// synchronized AAD DC Administrators group. The correlated Domain SID identifies Domain Users by RID 513. Principals
+// with AZManageEntraDS receive the broad synchronization edge only when Domain Users is reachable through AD
+// containment. The known Domain Controller Services service principal receives the filter-specific edge only when
+// filtered synchronization is enabled with sync scope All.
+func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, entraDSAdminGroupTenantMap map[graph.ID]string, syncedToEntraDSGroupEdgeMap, entraDSForEdgeMap, manageEntraDSSyncEdgeMap, manageEntraDSSyncFilterEdgeMap map[graph.ID][]graph.ID) error {
var (
- adGroupsByID = make(map[graph.ID]*graph.Node, len(adGroups))
- domainUsersByDomainSID = make(map[string][]graph.ID)
- domainUserGroupsByTenant = make(map[string][]graph.ID)
- scopedSyncAllowedByTenant = make(map[string]bool)
- seen = make(map[string]struct{})
+ adGroupsByID = make(map[graph.ID]*graph.Node, len(adGroups))
+ domainUsersByDomainSID = make(map[string][]graph.ID)
+ adminGroupDomainSIDsByTenant = make(map[string]map[string]struct{})
+ domainsByName = make(map[string][]*graph.Node)
+ scopedSyncTargetsByTenant = make(map[string][]graph.ID)
+ manageSyncSeen = make(map[string]struct{})
+ manageFilterSeen = make(map[string]struct{})
)
for _, adGroup := range adGroups {
adGroupsByID[adGroup.ID] = adGroup
- if objectID, err := adGroup.Properties.Get(common.ObjectID.String()).String(); errors.Is(err, graph.ErrPropertyNotFound) {
- continue
- } else if err != nil {
+ objectID, hasObjectID, err := normalizedNodeProperty(adGroup, common.ObjectID.String())
+ if err != nil {
return err
- } else if !strings.HasSuffix(strings.ToUpper(strings.TrimSpace(objectID)), domainUsersObjectIDSuffix) {
- continue
- } else if domainSID, err := adGroup.Properties.Get(adSchema.DomainSID.String()).String(); errors.Is(err, graph.ErrPropertyNotFound) {
+ } else if !hasObjectID {
continue
- } else if err != nil {
- return err
- } else if normalizedDomainSID := normalizeObjectID(domainSID); len(normalizedDomainSID) != 0 {
- domainUsersByDomainSID[normalizedDomainSID] = append(domainUsersByDomainSID[normalizedDomainSID], adGroup.ID)
}
- }
- if len(domainUsersByDomainSID) == 0 || len(entraDSAdminGroupTenantMap) == 0 {
- return nil
+ domainSID, hasDomainSID, err := normalizedNodeProperty(adGroup, adSchema.DomainSID.String())
+ if err != nil {
+ return err
+ } else if hasDomainSID && objectID == domainSID+domainUsersObjectIDSuffix {
+ domainUsersByDomainSID[domainSID] = append(domainUsersByDomainSID[domainSID], adGroup.ID)
+ }
}
for adAdminGroupID, azGroupIDs := range syncedToEntraDSGroupEdgeMap {
@@ -356,27 +381,35 @@ func addSyncEntraDSUsersEdges(tx graph.Transaction, adGroups []*graph.Node, entr
continue
}
- domainSID, err := adAdminGroup.Properties.Get(adSchema.DomainSID.String()).String()
- if errors.Is(err, graph.ErrPropertyNotFound) {
- continue
- } else if err != nil {
+ domainSID, hasDomainSID, err := normalizedNodeProperty(adAdminGroup, adSchema.DomainSID.String())
+ if err != nil {
return err
- }
-
- domainUserGroups := domainUsersByDomainSID[normalizeObjectID(domainSID)]
- if len(domainUserGroups) == 0 {
+ } else if !hasDomainSID {
continue
}
for _, azGroupID := range azGroupIDs {
if tenantID, isEntraDSAdminGroup := entraDSAdminGroupTenantMap[azGroupID]; isEntraDSAdminGroup {
- domainUserGroupsByTenant[tenantID] = append(domainUserGroupsByTenant[tenantID], domainUserGroups...)
+ if _, ok := adminGroupDomainSIDsByTenant[tenantID]; !ok {
+ adminGroupDomainSIDsByTenant[tenantID] = make(map[string]struct{})
+ }
+ adminGroupDomainSIDsByTenant[tenantID][domainSID] = struct{}{}
}
}
}
- if len(domainUserGroupsByTenant) == 0 {
- return nil
+ domains, err := fetchADDomains(tx)
+ if err != nil {
+ return err
+ }
+
+ for _, domain := range domains {
+ domainName, hasDomainName, err := normalizedNodeProperty(domain, common.Name.String())
+ if err != nil {
+ return err
+ } else if hasDomainName {
+ domainsByName[domainName] = append(domainsByName[domainName], domain)
+ }
}
domainServices, err := fetchEntraDomainServices(tx)
@@ -385,26 +418,76 @@ func addSyncEntraDSUsersEdges(tx graph.Transaction, adGroups []*graph.Node, entr
}
for _, domainService := range domainServices {
- domainServiceTenantID, err := domainService.Properties.Get(azure.TenantID.String()).String()
- if errors.Is(err, graph.ErrPropertyNotFound) {
+ tenantID, hasTenantID, err := normalizedNodeProperty(domainService, azure.TenantID.String())
+ if err != nil {
+ return err
+ } else if !hasTenantID {
continue
- } else if err != nil {
+ }
+
+ domainName, hasDomainName, err := normalizedNodeProperty(domainService, azure.DomainName.String())
+ if err != nil {
return err
+ } else if !hasDomainName {
+ continue
}
- normalizedTenantID := normalizeObjectID(domainServiceTenantID)
- for _, domainUserGroupID := range domainUserGroupsByTenant[normalizedTenantID] {
- addSyncEntraDSUsersEdge(syncEntraDSUsersEdgeMap, seen, domainService.ID, domainUserGroupID)
+ candidateDomains := domainsByName[domainName]
+ if len(candidateDomains) != 1 {
+ continue
+ }
+
+ domain := candidateDomains[0]
+ domainSID, hasDomainSID, err := normalizedNodeProperty(domain, adSchema.DomainSID.String())
+ if err != nil {
+ return err
+ } else if !hasDomainSID {
+ continue
+ } else if tenantDomainSIDs := adminGroupDomainSIDsByTenant[tenantID]; tenantDomainSIDs == nil {
+ continue
+ } else if _, corroborated := tenantDomainSIDs[domainSID]; !corroborated {
+ continue
+ }
+
+ addMappedRelationship(entraDSForEdgeMap, nil, domainService.ID, domain.ID)
+
+ domainUserGroups := domainUsersByDomainSID[domainSID]
+ if len(domainUserGroups) == 0 {
+ continue
+ }
+
+ containedDomainUserGroups, err := filterContainedDomainUsers(tx, domain, domainUserGroups)
+ if err != nil {
+ return err
+ }
+
+ if len(containedDomainUserGroups) > 0 {
+ managers, err := ops.FetchStartNodes(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.And(
+ query.InIDs(query.EndID(), domainService.ID),
+ query.Kind(query.Relationship(), azure.ManageEntraDS),
+ query.KindIn(query.Start(), azure.User, azure.Group, azure.ServicePrincipal),
+ )
+ }))
+ if err != nil {
+ return err
+ }
+
+ for _, manager := range managers {
+ for _, domainUserGroupID := range containedDomainUserGroups {
+ addMappedRelationship(manageEntraDSSyncEdgeMap, manageSyncSeen, manager.ID, domainUserGroupID)
+ }
+ }
}
if allowed, err := allowsScopedSyncServicePrincipalEdge(domainService); err != nil {
return err
} else if allowed {
- scopedSyncAllowedByTenant[normalizedTenantID] = true
+ scopedSyncTargetsByTenant[tenantID] = append(scopedSyncTargetsByTenant[tenantID], domainUserGroups...)
}
}
- if len(scopedSyncAllowedByTenant) == 0 {
+ if len(scopedSyncTargetsByTenant) == 0 {
return nil
}
@@ -425,33 +508,62 @@ func addSyncEntraDSUsersEdges(tx graph.Transaction, adGroups []*graph.Node, entr
return err
}
- applicationID, err := application.Properties.Get(common.ObjectID.String()).String()
+ applicationID, hasApplicationID, err := normalizedNodeProperty(application, common.ObjectID.String())
if err != nil {
return err
- } else if normalizeObjectID(applicationID) != entraDSScopedSyncApplicationID {
+ } else if !hasApplicationID || applicationID != entraDSScopedSyncApplicationID {
continue
}
- servicePrincipalTenantID, err := servicePrincipal.Properties.Get(azure.TenantID.String()).String()
+ servicePrincipalTenantID, hasTenantID, err := normalizedNodeProperty(servicePrincipal, azure.TenantID.String())
if err != nil {
return err
- }
-
- normalizedTenantID := normalizeObjectID(servicePrincipalTenantID)
- if !scopedSyncAllowedByTenant[normalizedTenantID] {
+ } else if !hasTenantID {
continue
}
- for _, domainUserGroupID := range domainUserGroupsByTenant[normalizedTenantID] {
- addSyncEntraDSUsersEdge(syncEntraDSUsersEdgeMap, seen, servicePrincipal.ID, domainUserGroupID)
+ for _, domainUserGroupID := range scopedSyncTargetsByTenant[servicePrincipalTenantID] {
+ addMappedRelationship(manageEntraDSSyncFilterEdgeMap, manageFilterSeen, servicePrincipal.ID, domainUserGroupID)
}
}
return nil
}
+func filterContainedDomainUsers(tx graph.Transaction, domain *graph.Node, domainUserGroupIDs []graph.ID) ([]graph.ID, error) {
+ targets := make(map[graph.ID]struct{}, len(domainUserGroupIDs))
+ for _, domainUserGroupID := range domainUserGroupIDs {
+ targets[domainUserGroupID] = struct{}{}
+ }
+
+ paths, err := ops.TraversePaths(tx, ops.TraversalPlan{
+ Root: domain,
+ Direction: graph.DirectionOutbound,
+ BranchQuery: func() graph.Criteria {
+ return query.Kind(query.Relationship(), adSchema.Contains)
+ },
+ PathFilter: func(_ *ops.TraversalContext, segment *graph.PathSegment) bool {
+ _, isDomainUserGroup := targets[segment.Node.ID]
+ return isDomainUserGroup
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ reachable := paths.AllNodes()
+ containedDomainUserGroupIDs := make([]graph.ID, 0, len(domainUserGroupIDs))
+ for _, domainUserGroupID := range domainUserGroupIDs {
+ if _, ok := reachable[domainUserGroupID]; ok {
+ containedDomainUserGroupIDs = append(containedDomainUserGroupIDs, domainUserGroupID)
+ }
+ }
+
+ return containedDomainUserGroupIDs, nil
+}
+
func allowsScopedSyncServicePrincipalEdge(domainService *graph.Node) (bool, error) {
- filteredSync, err := domainService.Properties.Get(azure.FilteredSync.String()).String()
+ filteredSyncEnabled, err := domainService.Properties.Get(azure.FilteredSyncEnabled.String()).Bool()
if errors.Is(err, graph.ErrPropertyNotFound) {
return false, nil
} else if err != nil {
@@ -465,17 +577,19 @@ func allowsScopedSyncServicePrincipalEdge(domainService *graph.Node) (bool, erro
return false, err
}
- return normalizeObjectID(filteredSync) == entraDSFilteredSyncEnabled && normalizeObjectID(syncScope) == entraDSSyncScopeAll, nil
+ return filteredSyncEnabled && normalizeObjectID(syncScope) == entraDSSyncScopeAll, nil
}
-func addSyncEntraDSUsersEdge(syncEntraDSUsersEdgeMap map[graph.ID][]graph.ID, seen map[string]struct{}, sourceNodeID, domainUserGroupID graph.ID) {
- key := sourceNodeID.String() + "|" + domainUserGroupID.String()
- if _, duplicate := seen[key]; duplicate {
- return
+func addMappedRelationship(edgeMap map[graph.ID][]graph.ID, seen map[string]struct{}, sourceNodeID, targetNodeID graph.ID) {
+ if seen != nil {
+ key := sourceNodeID.String() + "|" + targetNodeID.String()
+ if _, duplicate := seen[key]; duplicate {
+ return
+ }
+ seen[key] = struct{}{}
}
- seen[key] = struct{}{}
- syncEntraDSUsersEdgeMap[sourceNodeID] = append(syncEntraDSUsersEdgeMap[sourceNodeID], domainUserGroupID)
+ edgeMap[sourceNodeID] = append(edgeMap[sourceNodeID], targetNodeID)
}
// addAddEntraDSGroupMemberEdges computes the AddEntraDSGroupMember edges. An edge is created from an AZUser to an
@@ -550,6 +664,18 @@ func normalizeObjectID(objectID string) string {
return strings.ToUpper(strings.TrimSpace(objectID))
}
+func normalizedNodeProperty(node *graph.Node, property string) (string, bool, error) {
+ value, err := node.Properties.Get(property).String()
+ if errors.Is(err, graph.ErrPropertyNotFound) {
+ return "", false, nil
+ } else if err != nil {
+ return "", false, err
+ }
+
+ normalizedValue := normalizeObjectID(value)
+ return normalizedValue, normalizedValue != "", nil
+}
+
// hasOnPremUser takes a node and returns the OnPremID as a string, whether the node has an onPrem user defined as a bool
// and any errors in negotiation of the required properties
func hasOnPremUser(node *graph.Node) (string, bool, error) {
@@ -594,6 +720,12 @@ func fetchEntraDomainServices(tx graph.Transaction) ([]*graph.Node, error) {
}))
}
+func fetchADDomains(tx graph.Transaction) ([]*graph.Node, error) {
+ return ops.FetchNodes(tx.Nodes().Filterf(func() graph.Criteria {
+ return query.Kind(query.Node(), adSchema.Domain)
+ }))
+}
+
// fetchADUsers gets all AD Users in the graph
func fetchADUsers(tx graph.Transaction) ([]*graph.Node, error) {
return ops.FetchNodes(tx.Nodes().Filterf(func() graph.Criteria {
diff --git a/packages/go/analysis/hybrid/hybrid_integration_test.go b/packages/go/analysis/hybrid/hybrid_integration_test.go
index adfd80dae40e..3e2ee25be575 100644
--- a/packages/go/analysis/hybrid/hybrid_integration_test.go
+++ b/packages/go/analysis/hybrid/hybrid_integration_test.go
@@ -24,7 +24,6 @@ import (
"testing"
"github.com/specterops/bloodhound/cmd/api/src/test/integration"
- analysisAD "github.com/specterops/bloodhound/packages/go/analysis/ad"
"github.com/specterops/bloodhound/packages/go/analysis/post"
"github.com/specterops/bloodhound/packages/go/graphschema"
"github.com/specterops/bloodhound/packages/go/graphschema/ad"
@@ -34,6 +33,7 @@ import (
"github.com/specterops/dawgs/ops"
"github.com/specterops/dawgs/query"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestHybridAttackPaths(t *testing.T) {
@@ -456,7 +456,7 @@ func TestGetAddEntraDSGroupMemberEdgeComposition(t *testing.T) {
return nil
})
- composition, err := analysisAD.GetAddEntraDSGroupMemberEdgeComposition(context.Background(), db, edge)
+ composition, err := GetAddEntraDSGroupMemberEdgeComposition(context.Background(), db, edge)
assert.Nil(t, err)
nodes := composition.AllNodes()
@@ -469,370 +469,359 @@ func TestGetAddEntraDSGroupMemberEdgeComposition(t *testing.T) {
)
}
-func TestSyncEntraDSUsersEdge(t *testing.T) {
- t.Run("DomainServiceEdgeCreatedAcrossSyncScopes", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
- var domainService, domainUsers *graph.Node
-
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- domainService, _, _, _, _, domainUsers = setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: true,
- matchingDomainSID: true,
- filteredSync: "Disabled",
- syncScope: "CloudOnly",
- })
- return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
-
- verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.EntraDS,
- })
- },
- )
- })
-
- t.Run("ServicePrincipalEdgeCreatedWhenFilteredSyncEnabledAndScopeAll", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
- var domainService, servicePrincipal, domainUsers *graph.Node
-
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- domainService, _, servicePrincipal, _, _, domainUsers = setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: true,
- matchingDomainSID: true,
- filteredSync: "Enabled",
- syncScope: "All",
- })
- return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
-
- verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.EntraDS,
- getObjectID(t, servicePrincipal): azure.ServicePrincipal,
- })
- },
- )
- })
-
- t.Run("ServicePrincipalEdgeNotCreatedWhenFilteredSyncDisabled", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
- var domainService, domainUsers *graph.Node
-
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- domainService, _, _, _, _, domainUsers = setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: true,
- matchingDomainSID: true,
- filteredSync: "Disabled",
- syncScope: "All",
- })
- return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
-
- verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.EntraDS,
- })
- },
- )
- })
-
- t.Run("ServicePrincipalEdgeNotCreatedWhenSyncScopeCloudOnly", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
- var domainService, domainUsers *graph.Node
-
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- domainService, _, _, _, _, domainUsers = setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: true,
- matchingDomainSID: true,
- filteredSync: "Enabled",
- syncScope: "CloudOnly",
- })
- return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
-
- verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.EntraDS,
- })
- },
- )
- })
-
- t.Run("ServicePrincipalEdgeNotCreatedForWrongApplication", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
- var domainService, domainUsers *graph.Node
-
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- domainService, _, _, _, _, domainUsers = setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: integration.RandomObjectID(t),
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: true,
- matchingDomainSID: true,
- filteredSync: "Enabled",
- syncScope: "All",
- })
- return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
+func TestManageEntraDSSyncEdges(t *testing.T) {
+ testCases := []struct {
+ name string
+ options manageEntraDSSyncHarnessOptions
+ expectCorrelation bool
+ expectManageSync bool
+ expectManageFilter bool
+ }{
+ {
+ name: "BothEdgesCreatedForCorrelatedDomainAndEnabledFilter",
+ options: validManageEntraDSSyncOptions(),
+ expectCorrelation: true, expectManageSync: true, expectManageFilter: true,
+ },
+ {
+ name: "BroadEdgeIgnoresCurrentSynchronizationBoundary",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.filteredSyncEnabled = false
+ options.syncScope = "CloudOnly"
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: true, expectManageFilter: false,
+ },
+ {
+ name: "FilterEdgeRequiresFilteredSyncEnabled",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.filteredSyncEnabled = false
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: true, expectManageFilter: false,
+ },
+ {
+ name: "FilterEdgeRequiresSyncScopeAll",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.syncScope = "CloudOnly"
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: true, expectManageFilter: false,
+ },
+ {
+ name: "FilterEdgeRequiresKnownApplication",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.applicationID = integration.RandomObjectID(t)
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: true, expectManageFilter: false,
+ },
+ {
+ name: "FilterEdgeRequiresSameTenant",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.sameTenant = false
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: true, expectManageFilter: false,
+ },
+ {
+ name: "CorrelationRequiresAADDCAdministratorsName",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.adminGroupName = "NOT AAD DC ADMINISTRATORS@SPECTER.DEV"
+ return options
+ }(),
+ },
+ {
+ name: "CorrelationRequiresSynchronizedAADDCAdministrators",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.syncAdminGroup = false
+ return options
+ }(),
+ },
+ {
+ name: "CorrelationRequiresMatchingDomainName",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.matchingDomainName = false
+ return options
+ }(),
+ },
+ {
+ name: "CorrelationRequiresMatchingAdminGroupDomainSID",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.matchingAdminGroupDomainSID = false
+ return options
+ }(),
+ },
+ {
+ name: "DomainUsersRequiresRID513InCorrelatedDomain",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.matchingDomainUsersSID = false
+ return options
+ }(),
+ expectCorrelation: true,
+ },
+ {
+ name: "BroadSyncRequiresDomainUsersContainment",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.containDomainUsers = false
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: false, expectManageFilter: true,
+ },
+ }
- verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.EntraDS,
- })
- },
- )
- })
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var syncHarness manageEntraDSSyncHarness
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ syncHarness = setupManageEntraDSSyncHarness(t, testContext, testCase.options)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ _, err := PostHybrid(context.Background(), db)
+ require.NoError(t, err)
+ verifyManageEntraDSSyncEdges(t, db, syncHarness, testCase.expectCorrelation, testCase.expectManageSync, testCase.expectManageFilter)
+ },
+ )
+ })
+ }
- t.Run("EdgeNotCreatedForUnrelatedGroup", func(t *testing.T) {
+ t.Run("AmbiguousDomainNameFailsClosed", func(t *testing.T) {
testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var syncHarness manageEntraDSSyncHarness
testContext.DatabaseTestWithSetup(
func(harness *integration.HarnessDetails) error {
- setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: "NOT AAD DC ADMINISTRATORS@SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: true,
- matchingDomainSID: true,
- filteredSync: "Enabled",
- syncScope: "All",
- })
+ syncHarness = setupManageEntraDSSyncHarness(t, testContext, validManageEntraDSSyncOptions())
+ testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "specter.dev",
+ common.ObjectID: integration.RandomDomainSID(),
+ ad.DomainSID: integration.RandomDomainSID(),
+ }), ad.Entity, ad.Domain)
return nil
},
func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
-
- verifySyncEntraDSUsersEdges(t, db, "", nil)
+ _, err := PostHybrid(context.Background(), db)
+ require.NoError(t, err)
+ verifyManageEntraDSSyncEdges(t, db, syncHarness, false, false, false)
},
)
})
+}
- t.Run("EdgeNotCreatedWhenAdminGroupIsNotSynced", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
-
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: false,
- matchingDomainSID: true,
- filteredSync: "Enabled",
- syncScope: "All",
- })
- return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
-
- verifySyncEntraDSUsersEdges(t, db, "", nil)
- },
- )
- })
+func TestGetManageEntraDSSyncEdgeComposition(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var syncHarness manageEntraDSSyncHarness
+ var container *graph.Node
+ var unrelatedDomainService *graph.Node
+ var unrelatedDomain *graph.Node
- t.Run("EdgeNotCreatedForDifferentDomainSID", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ options := validManageEntraDSSyncOptions()
+ options.containDomainUsers = false
+ syncHarness = setupManageEntraDSSyncHarness(t, testContext, options)
+ container = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "USERS@SPECTER.DEV",
+ common.ObjectID: integration.RandomObjectID(t),
+ }), ad.Entity, ad.Container)
+ testContext.NewRelationship(syncHarness.domain, container, ad.Contains)
+ testContext.NewRelationship(container, syncHarness.domainUsers, ad.Contains)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ _, err := PostHybrid(context.Background(), db)
+ require.NoError(t, err)
+
+ unrelatedDomainService = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "UNRELATED.SPECTER.DEV",
+ common.ObjectID: integration.RandomObjectID(t),
+ }), azure.Entity, azure.EntraDS)
+ unrelatedDomain = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "UNRELATED.SPECTER.DEV",
+ common.ObjectID: integration.RandomDomainSID(),
+ ad.DomainSID: integration.RandomDomainSID(),
+ }), ad.Entity, ad.Domain)
+ testContext.NewRelationship(syncHarness.manager, unrelatedDomainService, azure.ManageEntraDS)
+ testContext.NewRelationship(unrelatedDomainService, unrelatedDomain, azure.EntraDSFor)
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: true,
- syncAdminGroup: true,
- matchingDomainSID: false,
- filteredSync: "Enabled",
- syncScope: "All",
- })
+ var edge *graph.Relationship
+ err = db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
+ edges, err := ops.FetchRelationships(tx.Relationships().Filter(query.Kind(query.Relationship(), azure.ManageEntraDSSync)))
+ require.NoError(t, err)
+ require.Len(t, edges, 1)
+ edge = edges[0]
return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
-
- verifySyncEntraDSUsersEdges(t, db, "", nil)
- },
- )
- })
+ })
+ require.NoError(t, err)
- t.Run("ServicePrincipalEdgeNotCreatedAcrossTenants", func(t *testing.T) {
- testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
- var domainService, domainUsers *graph.Node
+ composition, err := GetManageEntraDSSyncEdgeComposition(context.Background(), db, edge)
+ require.NoError(t, err)
+ nodes := composition.AllNodes()
+ assert.True(t, nodes.Contains(syncHarness.manager))
+ assert.True(t, nodes.Contains(syncHarness.domainService))
+ assert.True(t, nodes.Contains(syncHarness.domain))
+ assert.True(t, nodes.Contains(container))
+ assert.True(t, nodes.Contains(syncHarness.domainUsers))
+ assert.False(t, nodes.Contains(syncHarness.azAdminGroup))
+ assert.False(t, nodes.Contains(syncHarness.adAdminGroup))
+ assert.False(t, nodes.Contains(unrelatedDomainService))
+ assert.False(t, nodes.Contains(unrelatedDomain))
+ },
+ )
+}
- testContext.DatabaseTestWithSetup(
- func(harness *integration.HarnessDetails) error {
- domainService, _, _, _, _, domainUsers = setupSyncEntraDSUsersHarness(t, testContext, syncEntraDSUsersHarnessOptions{
- applicationID: entraDSScopedSyncApplicationID,
- adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
- sameTenant: false,
- syncAdminGroup: true,
- matchingDomainSID: true,
- filteredSync: "Enabled",
- syncScope: "All",
- })
- return nil
- },
- func(harness integration.HarnessDetails, db graph.Database) {
- if _, err := PostHybrid(context.Background(), db); err != nil {
- t.Fatalf("failed post processing for SyncEntraDSUsers edge: %v", err)
- }
+type manageEntraDSSyncHarnessOptions struct {
+ applicationID string
+ adminGroupName string
+ sameTenant bool
+ syncAdminGroup bool
+ matchingDomainName bool
+ matchingAdminGroupDomainSID bool
+ matchingDomainUsersSID bool
+ containDomainUsers bool
+ filteredSyncEnabled bool
+ syncScope string
+}
- verifySyncEntraDSUsersEdges(t, db, getObjectID(t, domainUsers), map[string]graph.Kind{
- getObjectID(t, domainService): azure.EntraDS,
- })
- },
- )
- })
+type manageEntraDSSyncHarness struct {
+ domainService, application, servicePrincipal, manager, azAdminGroup, adAdminGroup, domain, domainUsers *graph.Node
}
-type syncEntraDSUsersHarnessOptions struct {
- applicationID string
- adminGroupName string
- sameTenant bool
- syncAdminGroup bool
- matchingDomainSID bool
- filteredSync string
- syncScope string
+func validManageEntraDSSyncOptions() manageEntraDSSyncHarnessOptions {
+ return manageEntraDSSyncHarnessOptions{
+ applicationID: entraDSScopedSyncApplicationID,
+ adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
+ sameTenant: true,
+ syncAdminGroup: true,
+ matchingDomainName: true,
+ matchingAdminGroupDomainSID: true,
+ matchingDomainUsersSID: true,
+ containDomainUsers: true,
+ filteredSyncEnabled: true,
+ syncScope: "All",
+ }
}
-func setupSyncEntraDSUsersHarness(t *testing.T, testContext *integration.GraphTestContext, options syncEntraDSUsersHarnessOptions) (domainService, application, servicePrincipal, azAdminGroup, adAdminGroup, domainUsers *graph.Node) {
+func setupManageEntraDSSyncHarness(t *testing.T, testContext *integration.GraphTestContext, options manageEntraDSSyncHarnessOptions) manageEntraDSSyncHarness {
t.Helper()
var (
- adminGroupTenantID = integration.RandomObjectID(t)
- servicePrincipalTenantID = adminGroupTenantID
+ tenantID = integration.RandomObjectID(t)
+ servicePrincipalTenantID = tenantID
domainSID = integration.RandomDomainSID()
+ adminGroupDomainSID = domainSID
domainUsersDomainSID = domainSID
- filteredSync = options.filteredSync
- syncScope = options.syncScope
+ domainName = "SPECTER.DEV"
+ domainServiceDomainName = " specter.dev "
)
if !options.sameTenant {
servicePrincipalTenantID = integration.RandomObjectID(t)
}
- if !options.matchingDomainSID {
- domainUsersDomainSID = integration.RandomDomainSID()
+ if !options.matchingDomainName {
+ domainServiceDomainName = "other.example"
}
- if filteredSync == "" {
- filteredSync = "Disabled"
+ if !options.matchingAdminGroupDomainSID {
+ adminGroupDomainSID = integration.RandomDomainSID()
}
- if syncScope == "" {
- syncScope = "CloudOnly"
+ if !options.matchingDomainUsersSID {
+ domainUsersDomainSID = integration.RandomDomainSID()
}
- adminGroupTenant := testContext.NewAzureTenant(adminGroupTenantID)
- servicePrincipalTenant := adminGroupTenant
+ tenant := testContext.NewAzureTenant(tenantID)
+ servicePrincipalTenant := tenant
if !options.sameTenant {
servicePrincipalTenant = testContext.NewAzureTenant(servicePrincipalTenantID)
}
- domainService = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
- common.Name: "SPECTER.DEV",
- common.ObjectID: integration.RandomObjectID(t),
- azure.TenantID: adminGroupTenantID,
- azure.FilteredSync: filteredSync,
- azure.SyncScope: syncScope,
+ domainService := testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "Managed Domain",
+ common.ObjectID: integration.RandomObjectID(t),
+ azure.TenantID: tenantID,
+ azure.DomainName: domainServiceDomainName,
+ azure.FilteredSyncEnabled: options.filteredSyncEnabled,
+ azure.SyncScope: options.syncScope,
}), azure.Entity, azure.EntraDS)
- application = testContext.NewAzureApplication("Domain Controller Services", options.applicationID, servicePrincipalTenantID)
- servicePrincipal = testContext.NewAzureServicePrincipal("Domain Controller Services", integration.RandomObjectID(t), servicePrincipalTenantID)
+ application := testContext.NewAzureApplication("Domain Controller Services", options.applicationID, servicePrincipalTenantID)
+ servicePrincipal := testContext.NewAzureServicePrincipal("Domain Controller Services", integration.RandomObjectID(t), servicePrincipalTenantID)
+ manager := testContext.NewAzureGroup("Managed Domain Manager", integration.RandomObjectID(t), tenantID)
azAdminGroupObjectID := integration.RandomObjectID(t)
- azAdminGroup = testContext.NewAzureGroup(options.adminGroupName, azAdminGroupObjectID, adminGroupTenantID)
+ azAdminGroup := testContext.NewAzureGroup(options.adminGroupName, azAdminGroupObjectID, tenantID)
testContext.NewRelationship(application, servicePrincipal, azure.RunsAs)
testContext.NewRelationship(servicePrincipalTenant, servicePrincipal, azure.Contains)
- testContext.NewRelationship(adminGroupTenant, azAdminGroup, azure.Contains)
+ testContext.NewRelationship(tenant, manager, azure.Contains)
+ testContext.NewRelationship(tenant, azAdminGroup, azure.Contains)
+ testContext.NewRelationship(manager, domainService, azure.ManageEntraDS)
adminGroupAADObjectID := integration.RandomObjectID(t)
if options.syncAdminGroup {
adminGroupAADObjectID = azAdminGroupObjectID
}
- adAdminGroup = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ adAdminGroup := testContext.NewNode(graph.AsProperties(graph.PropertyMap{
common.Name: "AAD DC ADMINISTRATORS",
- common.ObjectID: domainSID + "-1104",
- ad.DomainSID: domainSID,
+ common.ObjectID: adminGroupDomainSID + "-1104",
+ ad.DomainSID: adminGroupDomainSID,
ad.AADObjectID: adminGroupAADObjectID,
}), ad.Entity, ad.Group)
- domainUsers = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ domain := testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: domainName,
+ common.ObjectID: domainSID,
+ ad.DomainSID: domainSID,
+ }), ad.Entity, ad.Domain)
+ domainUsers := testContext.NewNode(graph.AsProperties(graph.PropertyMap{
common.Name: "DOMAIN USERS",
common.ObjectID: domainUsersDomainSID + domainUsersObjectIDSuffix,
ad.DomainSID: domainUsersDomainSID,
}), ad.Entity, ad.Group)
+ if options.containDomainUsers {
+ testContext.NewRelationship(domain, domainUsers, ad.Contains)
+ }
- return domainService, application, servicePrincipal, azAdminGroup, adAdminGroup, domainUsers
+ return manageEntraDSSyncHarness{
+ domainService: domainService, application: application, servicePrincipal: servicePrincipal, manager: manager,
+ azAdminGroup: azAdminGroup, adAdminGroup: adAdminGroup, domain: domain, domainUsers: domainUsers,
+ }
}
-func verifySyncEntraDSUsersEdges(t *testing.T, db graph.Database, expectedEndObjectID string, expectedStartKinds map[string]graph.Kind) {
+func verifyManageEntraDSSyncEdges(t *testing.T, db graph.Database, syncHarness manageEntraDSSyncHarness, expectCorrelation, expectManageSync, expectManageFilter bool) {
t.Helper()
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.SyncEntraDSUsers)
- }))
- assert.Nil(t, err)
-
- if len(expectedStartKinds) == 0 {
- assert.Empty(t, edges)
- return nil
- }
-
- assert.Len(t, edges, len(expectedStartKinds))
- seen := make(map[string]struct{}, len(expectedStartKinds))
- for _, edge := range edges {
- start, end, err := ops.FetchRelationshipNodes(tx, edge)
- assert.Nil(t, err)
-
- startObjectID := getObjectID(t, start)
- expectedStartKind, ok := expectedStartKinds[startObjectID]
- assert.True(t, ok)
- if !ok {
+ for _, expectation := range []struct {
+ kind graph.Kind
+ start, end *graph.Node
+ shouldExist bool
+ }{
+ {azure.EntraDSFor, syncHarness.domainService, syncHarness.domain, expectCorrelation},
+ {azure.ManageEntraDSSync, syncHarness.manager, syncHarness.domainUsers, expectManageSync},
+ {azure.ManageEntraDSSyncFilter, syncHarness.servicePrincipal, syncHarness.domainUsers, expectManageFilter},
+ } {
+ edges, err := ops.FetchRelationships(tx.Relationships().Filter(query.Kind(query.Relationship(), expectation.kind)))
+ require.NoError(t, err)
+ if !expectation.shouldExist {
+ assert.Empty(t, edges)
continue
}
- assert.True(t, start.Kinds.ContainsOneOf(expectedStartKind))
- assert.True(t, end.Kinds.ContainsOneOf(ad.Group))
- assert.Equal(t, expectedEndObjectID, getObjectID(t, end))
- seen[startObjectID] = struct{}{}
+ require.Len(t, edges, 1)
+ assert.Equal(t, expectation.start.ID, edges[0].StartID)
+ assert.Equal(t, expectation.end.ID, edges[0].EndID)
}
- assert.Len(t, seen, len(expectedStartKinds))
return nil
})
diff --git a/packages/go/analysis/post/post_integration_test.go b/packages/go/analysis/post/post_integration_test.go
index 809d4ee1af79..1828f3c7490e 100644
--- a/packages/go/analysis/post/post_integration_test.go
+++ b/packages/go/analysis/post/post_integration_test.go
@@ -74,6 +74,16 @@ func TestDeleteTransitEdges(t *testing.T) {
"name": "managed_domain",
"objectid": "5678",
}), azure.Entity, azure.EntraDS)
+
+ adDomain = testCtx.NewNode(graph.AsProperties(map[string]any{
+ "name": "managed.example",
+ "objectid": "S-1-5-21-1-2-3",
+ }), ad.Entity, ad.Domain)
+
+ azureServicePrincipal = testCtx.NewNode(graph.AsProperties(map[string]any{
+ "name": "domain_controller_services",
+ "objectid": "8765",
+ }), azure.Entity, azure.ServicePrincipal)
)
// In order to validate that DeleteTransitEdges and the updated PostProcessedRelationships for both AD and Azure are correct, we need to simulate
@@ -85,7 +95,10 @@ func TestDeleteTransitEdges(t *testing.T) {
// in bhce/cmd/api/src/analysis/azure/post.go.
testCtx.NewRelationship(adUser, azureUser, azure.SyncedToEntraUser)
testCtx.NewRelationship(azureUser, adUser, azure.SyncedToEntraDSUser)
- testCtx.NewRelationship(domainService, adGroup, azure.SyncEntraDSUsers)
+ testCtx.NewRelationship(azureUser, domainService, azure.ManageEntraDS)
+ testCtx.NewRelationship(domainService, adDomain, azure.EntraDSFor)
+ testCtx.NewRelationship(azureUser, adGroup, azure.ManageEntraDSSync)
+ testCtx.NewRelationship(azureServicePrincipal, adGroup, azure.ManageEntraDSSyncFilter)
testCtx.NewRelationship(azureUser, adUser, ad.SyncedToADUser)
// The way post-processing operates is that all edges created during post-processing are deleted before each analysis run. This helps keep the graph consistent
@@ -108,9 +121,11 @@ func TestDeleteTransitEdges(t *testing.T) {
require.Nil(t, err)
require.Equal(t, int64(0), numEdges)
- numEdges, err = tx.Relationships().Filter(query.Kind(query.Relationship(), azure.SyncEntraDSUsers)).Count()
- require.Nil(t, err)
- require.Equal(t, int64(0), numEdges)
+ for _, relationshipKind := range []graph.Kind{azure.ManageEntraDS, azure.EntraDSFor, azure.ManageEntraDSSync, azure.ManageEntraDSSyncFilter} {
+ numEdges, err = tx.Relationships().Filter(query.Kind(query.Relationship(), relationshipKind)).Count()
+ require.Nil(t, err)
+ require.Equal(t, int64(0), numEdges)
+ }
return nil
})
diff --git a/packages/go/ein/azure_domain_service.go b/packages/go/ein/azure_domain_service.go
index 1d0c5a320695..2db3cc585538 100644
--- a/packages/go/ein/azure_domain_service.go
+++ b/packages/go/ein/azure_domain_service.go
@@ -66,32 +66,44 @@ type AzureDomainService struct {
}
func ConvertAzureDomainServiceToNode(data AzureDomainService, ingestTime time.Time) IngestibleNode {
- return IngestibleNode{
+ node := IngestibleNode{
ObjectID: data.ID,
PropertyMap: map[string]any{
- common.Name.String(): data.Name,
- common.LastCollected.String(): ingestTime,
- azure.TenantID.String(): strings.ToUpper(data.Properties.TenantID),
- azure.DomainName.String(): data.Properties.DomainName,
- azure.DomainConfigurationType.String(): data.Properties.DomainConfigurationType,
- azure.FilteredSync.String(): data.Properties.FilteredSync,
- azure.SyncScope.String(): data.Properties.SyncScope,
- azure.SyncApplicationID.String(): strings.ToUpper(data.Properties.SyncApplicationID),
- azure.NTLMV1.String(): data.Properties.DomainSecuritySettings.NTLMV1,
- azure.TLSV1.String(): data.Properties.DomainSecuritySettings.TLSV1,
- azure.SyncNTLMPasswords.String(): data.Properties.DomainSecuritySettings.SyncNTLMPasswords,
- azure.SyncKerberosPasswords.String(): data.Properties.DomainSecuritySettings.SyncKerberosPasswords,
- azure.SyncOnPremPasswords.String(): data.Properties.DomainSecuritySettings.SyncOnPremPasswords,
- azure.KerberosRC4Encryption.String(): data.Properties.DomainSecuritySettings.KerberosRC4Encryption,
- azure.KerberosArmoring.String(): data.Properties.DomainSecuritySettings.KerberosArmoring,
- azure.LDAPSigning.String(): data.Properties.DomainSecuritySettings.LDAPSigning,
- azure.ChannelBinding.String(): data.Properties.DomainSecuritySettings.ChannelBinding,
- azure.SyncOnPremSAMAccountName.String(): data.Properties.DomainSecuritySettings.SyncOnPremSAMAccountName,
- azure.LDAPS.String(): data.Properties.LDAPSSettings.LDAPS,
- azure.LDAPSExternalAccess.String(): data.Properties.LDAPSSettings.ExternalAccess,
+ common.Name.String(): data.Name,
+ common.LastCollected.String(): ingestTime,
+ azure.TenantID.String(): strings.ToUpper(data.Properties.TenantID),
+ azure.DomainName.String(): data.Properties.DomainName,
+ azure.DomainConfigurationType.String(): data.Properties.DomainConfigurationType,
+ azure.SyncScope.String(): data.Properties.SyncScope,
+ azure.SyncApplicationID.String(): strings.ToUpper(data.Properties.SyncApplicationID),
},
Labels: []graph.Kind{azure.EntraDS},
}
+
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.FilteredSyncEnabled.String(), data.Properties.FilteredSync)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.NTLMV1Enabled.String(), data.Properties.DomainSecuritySettings.NTLMV1)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.TLSV1Enabled.String(), data.Properties.DomainSecuritySettings.TLSV1)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.SyncNTLMPasswordsEnabled.String(), data.Properties.DomainSecuritySettings.SyncNTLMPasswords)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.SyncKerberosPasswordsEnabled.String(), data.Properties.DomainSecuritySettings.SyncKerberosPasswords)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.SyncOnPremPasswordsEnabled.String(), data.Properties.DomainSecuritySettings.SyncOnPremPasswords)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.KerberosRC4EncryptionEnabled.String(), data.Properties.DomainSecuritySettings.KerberosRC4Encryption)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.KerberosArmoringEnabled.String(), data.Properties.DomainSecuritySettings.KerberosArmoring)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.LDAPSigningEnabled.String(), data.Properties.DomainSecuritySettings.LDAPSigning)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.ChannelBindingEnabled.String(), data.Properties.DomainSecuritySettings.ChannelBinding)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.SyncOnPremSAMAccountNameEnabled.String(), data.Properties.DomainSecuritySettings.SyncOnPremSAMAccountName)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.LDAPSEnabled.String(), data.Properties.LDAPSSettings.LDAPS)
+ setAzureDomainServiceBooleanProperty(node.PropertyMap, azure.LDAPSExternalAccessEnabled.String(), data.Properties.LDAPSSettings.ExternalAccess)
+
+ return node
+}
+
+func setAzureDomainServiceBooleanProperty(properties map[string]any, propertyName, rawValue string) {
+ switch {
+ case strings.EqualFold(strings.TrimSpace(rawValue), "Enabled"):
+ properties[propertyName] = true
+ case strings.EqualFold(strings.TrimSpace(rawValue), "Disabled"):
+ properties[propertyName] = false
+ }
}
func ConvertAzureDomainServiceToRels(data AzureDomainService) []IngestibleRelationship {
diff --git a/packages/go/ein/azure_domain_service_test.go b/packages/go/ein/azure_domain_service_test.go
index 3e4005be934d..2429e16bf362 100644
--- a/packages/go/ein/azure_domain_service_test.go
+++ b/packages/go/ein/azure_domain_service_test.go
@@ -73,21 +73,42 @@ func TestConvertAzureDomainServiceToNode(t *testing.T) {
assert.Equal(t, strings.ToUpper(data.Properties.TenantID), node.PropertyMap[azure.TenantID.String()])
assert.Equal(t, data.Properties.DomainName, node.PropertyMap[azure.DomainName.String()])
assert.Equal(t, data.Properties.DomainConfigurationType, node.PropertyMap[azure.DomainConfigurationType.String()])
- assert.Equal(t, data.Properties.FilteredSync, node.PropertyMap[azure.FilteredSync.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.FilteredSyncEnabled.String()])
assert.Equal(t, data.Properties.SyncScope, node.PropertyMap[azure.SyncScope.String()])
assert.Equal(t, strings.ToUpper(data.Properties.SyncApplicationID), node.PropertyMap[azure.SyncApplicationID.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.NTLMV1, node.PropertyMap[azure.NTLMV1.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.TLSV1, node.PropertyMap[azure.TLSV1.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.SyncNTLMPasswords, node.PropertyMap[azure.SyncNTLMPasswords.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.SyncKerberosPasswords, node.PropertyMap[azure.SyncKerberosPasswords.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.SyncOnPremPasswords, node.PropertyMap[azure.SyncOnPremPasswords.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.KerberosRC4Encryption, node.PropertyMap[azure.KerberosRC4Encryption.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.KerberosArmoring, node.PropertyMap[azure.KerberosArmoring.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.LDAPSigning, node.PropertyMap[azure.LDAPSigning.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.ChannelBinding, node.PropertyMap[azure.ChannelBinding.String()])
- assert.Equal(t, data.Properties.DomainSecuritySettings.SyncOnPremSAMAccountName, node.PropertyMap[azure.SyncOnPremSAMAccountName.String()])
- assert.Equal(t, data.Properties.LDAPSSettings.LDAPS, node.PropertyMap[azure.LDAPS.String()])
- assert.Equal(t, data.Properties.LDAPSSettings.ExternalAccess, node.PropertyMap[azure.LDAPSExternalAccess.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.NTLMV1Enabled.String()])
+ assert.Equal(t, false, node.PropertyMap[azure.TLSV1Enabled.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.SyncNTLMPasswordsEnabled.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.SyncKerberosPasswordsEnabled.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.SyncOnPremPasswordsEnabled.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.KerberosRC4EncryptionEnabled.String()])
+ assert.Equal(t, false, node.PropertyMap[azure.KerberosArmoringEnabled.String()])
+ assert.Equal(t, false, node.PropertyMap[azure.LDAPSigningEnabled.String()])
+ assert.Equal(t, false, node.PropertyMap[azure.ChannelBindingEnabled.String()])
+ assert.Equal(t, false, node.PropertyMap[azure.SyncOnPremSAMAccountNameEnabled.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.LDAPSEnabled.String()])
+ assert.Equal(t, true, node.PropertyMap[azure.LDAPSExternalAccessEnabled.String()])
+}
+
+func TestConvertAzureDomainServiceToNodeOmitsUnknownBooleanSettings(t *testing.T) {
+ data := ein.AzureDomainService{
+ Properties: ein.AzureDomainServiceProperties{
+ FilteredSync: " enabled ",
+ DomainSecuritySettings: ein.AzureDomainServiceSecuritySettings{
+ NTLMV1: "FutureValue",
+ },
+ LDAPSSettings: ein.AzureDomainServiceLDAPSSettings{
+ LDAPS: "Disabled",
+ },
+ },
+ }
+
+ node := ein.ConvertAzureDomainServiceToNode(data, time.Time{})
+
+ assert.Equal(t, true, node.PropertyMap[azure.FilteredSyncEnabled.String()])
+ assert.Equal(t, false, node.PropertyMap[azure.LDAPSEnabled.String()])
+ assert.NotContains(t, node.PropertyMap, azure.NTLMV1Enabled.String())
+ assert.NotContains(t, node.PropertyMap, azure.TLSV1Enabled.String())
}
func TestConvertAzureDomainServiceToRels(t *testing.T) {
diff --git a/packages/go/graphschema/azure/azure.go b/packages/go/graphschema/azure/azure.go
index 26b28ac30990..2f685eeecd44 100644
--- a/packages/go/graphschema/azure/azure.go
+++ b/packages/go/graphschema/azure/azure.go
@@ -51,6 +51,7 @@ var (
Contains = graph.StringKind("AZContains")
Contributor = graph.StringKind("AZContributor")
EntraDSContributor = graph.StringKind("AZEntraDSContributor")
+ ManageEntraDS = graph.StringKind("AZManageEntraDS")
GetCertificates = graph.StringKind("AZGetCertificates")
GetKeys = graph.StringKind("AZGetKeys")
GetSecrets = graph.StringKind("AZGetSecrets")
@@ -98,7 +99,9 @@ var (
SyncedToEntraDSUser = graph.StringKind("SyncedToEntraDSUser")
SyncedToEntraDSGroup = graph.StringKind("SyncedToEntraDSGroup")
AddEntraDSGroupMember = graph.StringKind("AddEntraDSGroupMember")
- SyncEntraDSUsers = graph.StringKind("SyncEntraDSUsers")
+ EntraDSFor = graph.StringKind("EntraDSFor")
+ ManageEntraDSSync = graph.StringKind("ManageEntraDSSync")
+ ManageEntraDSSyncFilter = graph.StringKind("ManageEntraDSSyncFilter")
AZRoleEligible = graph.StringKind("AZRoleEligible")
AZRoleApprover = graph.StringKind("AZRoleApprover")
AZAuthenticatesTo = graph.StringKind("AZAuthenticatesTo")
@@ -153,25 +156,25 @@ const (
FederatedIdentityCredentialAppID Property = "federatedidentitycredentialappid"
DomainName Property = "domainname"
DomainConfigurationType Property = "domainconfigurationtype"
- FilteredSync Property = "filteredsync"
+ FilteredSyncEnabled Property = "filteredsyncenabled"
SyncScope Property = "syncscope"
SyncApplicationID Property = "syncapplicationid"
- NTLMV1 Property = "ntlmv1"
- TLSV1 Property = "tlsv1"
- SyncNTLMPasswords Property = "syncntlmpasswords"
- SyncKerberosPasswords Property = "synckerberospasswords"
- SyncOnPremPasswords Property = "synconprempasswords"
- KerberosRC4Encryption Property = "kerberosrc4encryption"
- KerberosArmoring Property = "kerberosarmoring"
- LDAPSigning Property = "ldapsigning"
- ChannelBinding Property = "channelbinding"
- SyncOnPremSAMAccountName Property = "synconpremsamaccountname"
- LDAPS Property = "ldaps"
- LDAPSExternalAccess Property = "ldapsexternalaccess"
+ NTLMV1Enabled Property = "ntlmv1enabled"
+ TLSV1Enabled Property = "tlsv1enabled"
+ SyncNTLMPasswordsEnabled Property = "syncntlmpasswordsenabled"
+ SyncKerberosPasswordsEnabled Property = "synckerberospasswordsenabled"
+ SyncOnPremPasswordsEnabled Property = "synconprempasswordsenabled"
+ KerberosRC4EncryptionEnabled Property = "kerberosrc4encryptionenabled"
+ KerberosArmoringEnabled Property = "kerberosarmoringenabled"
+ LDAPSigningEnabled Property = "ldapsigningenabled"
+ ChannelBindingEnabled Property = "channelbindingenabled"
+ SyncOnPremSAMAccountNameEnabled Property = "synconpremsamaccountnameenabled"
+ LDAPSEnabled Property = "ldapsenabled"
+ LDAPSExternalAccessEnabled Property = "ldapsexternalaccessenabled"
)
func AllProperties() []Property {
- return []Property{AppOwnerOrganizationID, AppDescription, AppDisplayName, ServicePrincipalType, UserType, TenantID, ServicePrincipalID, OperatingSystemVersion, TrustType, IsBuiltIn, AppID, AppRoleID, DeviceID, NodeResourceGroupID, OnPremID, OnPremSyncEnabled, SecurityEnabled, SecurityIdentifier, EnableRBACAuthorization, Scope, Offer, MFAEnabled, License, Licenses, LoginURL, MFAEnforced, UserPrincipalName, IsAssignableToRole, PublisherDomain, SignInAudience, RoleTemplateID, RoleDefinitionId, EndUserAssignmentRequiresApproval, EndUserAssignmentRequiresCAPAuthenticationContext, EndUserAssignmentUserApprovers, EndUserAssignmentGroupApprovers, EndUserAssignmentRequiresMFA, EndUserAssignmentRequiresJustification, EndUserAssignmentRequiresTicketInformation, LastSuccessfulSignInDateTime, Issuer, Subject, Audiences, FederatedIdentityCredentialAppID, DomainName, DomainConfigurationType, FilteredSync, SyncScope, SyncApplicationID, NTLMV1, TLSV1, SyncNTLMPasswords, SyncKerberosPasswords, SyncOnPremPasswords, KerberosRC4Encryption, KerberosArmoring, LDAPSigning, ChannelBinding, SyncOnPremSAMAccountName, LDAPS, LDAPSExternalAccess}
+ return []Property{AppOwnerOrganizationID, AppDescription, AppDisplayName, ServicePrincipalType, UserType, TenantID, ServicePrincipalID, OperatingSystemVersion, TrustType, IsBuiltIn, AppID, AppRoleID, DeviceID, NodeResourceGroupID, OnPremID, OnPremSyncEnabled, SecurityEnabled, SecurityIdentifier, EnableRBACAuthorization, Scope, Offer, MFAEnabled, License, Licenses, LoginURL, MFAEnforced, UserPrincipalName, IsAssignableToRole, PublisherDomain, SignInAudience, RoleTemplateID, RoleDefinitionId, EndUserAssignmentRequiresApproval, EndUserAssignmentRequiresCAPAuthenticationContext, EndUserAssignmentUserApprovers, EndUserAssignmentGroupApprovers, EndUserAssignmentRequiresMFA, EndUserAssignmentRequiresJustification, EndUserAssignmentRequiresTicketInformation, LastSuccessfulSignInDateTime, Issuer, Subject, Audiences, FederatedIdentityCredentialAppID, DomainName, DomainConfigurationType, FilteredSyncEnabled, SyncScope, SyncApplicationID, NTLMV1Enabled, TLSV1Enabled, SyncNTLMPasswordsEnabled, SyncKerberosPasswordsEnabled, SyncOnPremPasswordsEnabled, KerberosRC4EncryptionEnabled, KerberosArmoringEnabled, LDAPSigningEnabled, ChannelBindingEnabled, SyncOnPremSAMAccountNameEnabled, LDAPSEnabled, LDAPSExternalAccessEnabled}
}
func ParseProperty(source string) (Property, error) {
switch source {
@@ -267,36 +270,36 @@ func ParseProperty(source string) (Property, error) {
return DomainName, nil
case "domainconfigurationtype":
return DomainConfigurationType, nil
- case "filteredsync":
- return FilteredSync, nil
+ case "filteredsyncenabled":
+ return FilteredSyncEnabled, nil
case "syncscope":
return SyncScope, nil
case "syncapplicationid":
return SyncApplicationID, nil
- case "ntlmv1":
- return NTLMV1, nil
- case "tlsv1":
- return TLSV1, nil
- case "syncntlmpasswords":
- return SyncNTLMPasswords, nil
- case "synckerberospasswords":
- return SyncKerberosPasswords, nil
- case "synconprempasswords":
- return SyncOnPremPasswords, nil
- case "kerberosrc4encryption":
- return KerberosRC4Encryption, nil
- case "kerberosarmoring":
- return KerberosArmoring, nil
- case "ldapsigning":
- return LDAPSigning, nil
- case "channelbinding":
- return ChannelBinding, nil
- case "synconpremsamaccountname":
- return SyncOnPremSAMAccountName, nil
- case "ldaps":
- return LDAPS, nil
- case "ldapsexternalaccess":
- return LDAPSExternalAccess, nil
+ case "ntlmv1enabled":
+ return NTLMV1Enabled, nil
+ case "tlsv1enabled":
+ return TLSV1Enabled, nil
+ case "syncntlmpasswordsenabled":
+ return SyncNTLMPasswordsEnabled, nil
+ case "synckerberospasswordsenabled":
+ return SyncKerberosPasswordsEnabled, nil
+ case "synconprempasswordsenabled":
+ return SyncOnPremPasswordsEnabled, nil
+ case "kerberosrc4encryptionenabled":
+ return KerberosRC4EncryptionEnabled, nil
+ case "kerberosarmoringenabled":
+ return KerberosArmoringEnabled, nil
+ case "ldapsigningenabled":
+ return LDAPSigningEnabled, nil
+ case "channelbindingenabled":
+ return ChannelBindingEnabled, nil
+ case "synconpremsamaccountnameenabled":
+ return SyncOnPremSAMAccountNameEnabled, nil
+ case "ldapsenabled":
+ return LDAPSEnabled, nil
+ case "ldapsexternalaccessenabled":
+ return LDAPSExternalAccessEnabled, nil
default:
return "", errors.New("Invalid enumeration value: " + source)
}
@@ -395,36 +398,36 @@ func (s Property) String() string {
return string(DomainName)
case DomainConfigurationType:
return string(DomainConfigurationType)
- case FilteredSync:
- return string(FilteredSync)
+ case FilteredSyncEnabled:
+ return string(FilteredSyncEnabled)
case SyncScope:
return string(SyncScope)
case SyncApplicationID:
return string(SyncApplicationID)
- case NTLMV1:
- return string(NTLMV1)
- case TLSV1:
- return string(TLSV1)
- case SyncNTLMPasswords:
- return string(SyncNTLMPasswords)
- case SyncKerberosPasswords:
- return string(SyncKerberosPasswords)
- case SyncOnPremPasswords:
- return string(SyncOnPremPasswords)
- case KerberosRC4Encryption:
- return string(KerberosRC4Encryption)
- case KerberosArmoring:
- return string(KerberosArmoring)
- case LDAPSigning:
- return string(LDAPSigning)
- case ChannelBinding:
- return string(ChannelBinding)
- case SyncOnPremSAMAccountName:
- return string(SyncOnPremSAMAccountName)
- case LDAPS:
- return string(LDAPS)
- case LDAPSExternalAccess:
- return string(LDAPSExternalAccess)
+ case NTLMV1Enabled:
+ return string(NTLMV1Enabled)
+ case TLSV1Enabled:
+ return string(TLSV1Enabled)
+ case SyncNTLMPasswordsEnabled:
+ return string(SyncNTLMPasswordsEnabled)
+ case SyncKerberosPasswordsEnabled:
+ return string(SyncKerberosPasswordsEnabled)
+ case SyncOnPremPasswordsEnabled:
+ return string(SyncOnPremPasswordsEnabled)
+ case KerberosRC4EncryptionEnabled:
+ return string(KerberosRC4EncryptionEnabled)
+ case KerberosArmoringEnabled:
+ return string(KerberosArmoringEnabled)
+ case LDAPSigningEnabled:
+ return string(LDAPSigningEnabled)
+ case ChannelBindingEnabled:
+ return string(ChannelBindingEnabled)
+ case SyncOnPremSAMAccountNameEnabled:
+ return string(SyncOnPremSAMAccountNameEnabled)
+ case LDAPSEnabled:
+ return string(LDAPSEnabled)
+ case LDAPSExternalAccessEnabled:
+ return string(LDAPSExternalAccessEnabled)
default:
return "Invalid enumeration case: " + string(s)
}
@@ -523,36 +526,36 @@ func (s Property) Name() string {
return "Domain Name"
case DomainConfigurationType:
return "Domain Configuration Type"
- case FilteredSync:
- return "Filtered Sync"
+ case FilteredSyncEnabled:
+ return "Filtered Sync Enabled"
case SyncScope:
return "Sync Scope"
case SyncApplicationID:
return "Sync Application ID"
- case NTLMV1:
- return "NTLM V1"
- case TLSV1:
- return "TLS V1"
- case SyncNTLMPasswords:
- return "Sync NTLM Passwords"
- case SyncKerberosPasswords:
- return "Sync Kerberos Passwords"
- case SyncOnPremPasswords:
- return "Sync On-Premises Passwords"
- case KerberosRC4Encryption:
- return "Kerberos RC4 Encryption"
- case KerberosArmoring:
- return "Kerberos Armoring"
- case LDAPSigning:
- return "LDAP Signing"
- case ChannelBinding:
- return "Channel Binding"
- case SyncOnPremSAMAccountName:
- return "Sync On-Premises SAM Account Name"
- case LDAPS:
- return "Secure LDAP"
- case LDAPSExternalAccess:
- return "Secure LDAP External Access"
+ case NTLMV1Enabled:
+ return "NTLM V1 Enabled"
+ case TLSV1Enabled:
+ return "TLS V1 Enabled"
+ case SyncNTLMPasswordsEnabled:
+ return "Sync NTLM Passwords Enabled"
+ case SyncKerberosPasswordsEnabled:
+ return "Sync Kerberos Passwords Enabled"
+ case SyncOnPremPasswordsEnabled:
+ return "Sync On-Premises Passwords Enabled"
+ case KerberosRC4EncryptionEnabled:
+ return "Kerberos RC4 Encryption Enabled"
+ case KerberosArmoringEnabled:
+ return "Kerberos Armoring Enabled"
+ case LDAPSigningEnabled:
+ return "LDAP Signing Enabled"
+ case ChannelBindingEnabled:
+ return "Channel Binding Enabled"
+ case SyncOnPremSAMAccountNameEnabled:
+ return "Sync On-Premises SAM Account Name Enabled"
+ case LDAPSEnabled:
+ return "Secure LDAP Enabled"
+ case LDAPSExternalAccessEnabled:
+ return "Secure LDAP External Access Enabled"
default:
return "Invalid enumeration case: " + string(s)
}
@@ -566,7 +569,7 @@ func (s Property) Is(others ...graph.Kind) bool {
return false
}
func Relationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contains, Contributor, EntraDSContributor, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, ScopedTo, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, ApplicationReadWriteAll, AppRoleAssignmentReadWriteAll, DirectoryReadWriteAll, GroupReadWriteAll, GroupMemberReadWriteAll, RoleManagementReadWriteDirectory, ServicePrincipalEndpointReadWriteAll, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, SyncedToEntraDSGroup, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleEligible, AZRoleApprover, AZAuthenticatesTo}
+ return []graph.Kind{AvereContributor, Contains, Contributor, EntraDSContributor, ManageEntraDS, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, ScopedTo, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, ApplicationReadWriteAll, AppRoleAssignmentReadWriteAll, DirectoryReadWriteAll, GroupReadWriteAll, GroupMemberReadWriteAll, RoleManagementReadWriteDirectory, ServicePrincipalEndpointReadWriteAll, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, SyncedToEntraDSGroup, AddEntraDSGroupMember, EntraDSFor, ManageEntraDSSync, ManageEntraDSSyncFilter, AZRoleEligible, AZRoleApprover, AZAuthenticatesTo}
}
func AppRoleTransitRelationshipKinds() []graph.Kind {
return []graph.Kind{AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole}
@@ -575,16 +578,16 @@ func AbusableAppRoleRelationshipKinds() []graph.Kind {
return []graph.Kind{ApplicationReadWriteAll, AppRoleAssignmentReadWriteAll, DirectoryReadWriteAll, GroupReadWriteAll, GroupMemberReadWriteAll, RoleManagementReadWriteDirectory, ServicePrincipalEndpointReadWriteAll}
}
func ControlRelationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contributor, EntraDSContributor, Owner, VMContributor, AutomationContributor, KeyVaultContributor, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, AZAuthenticatesTo}
+ return []graph.Kind{AvereContributor, Contributor, ManageEntraDS, Owner, VMContributor, AutomationContributor, KeyVaultContributor, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, AZAuthenticatesTo}
}
func ExecutionPrivileges() []graph.Kind {
return []graph.Kind{VMAdminLogin, VMContributor, AvereContributor, WebsiteContributor, Contributor, ExecuteCommand}
}
func PathfindingRelationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contributor, EntraDSContributor, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleEligible, AZRoleApprover, Contains, AZAuthenticatesTo}
+ return []graph.Kind{AvereContributor, Contributor, ManageEntraDS, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToEntraUser, SyncedToEntraDSUser, AddEntraDSGroupMember, ManageEntraDSSync, ManageEntraDSSyncFilter, AZRoleEligible, AZRoleApprover, Contains, AZAuthenticatesTo}
}
func PostProcessedRelationships() []graph.Kind {
- return []graph.Kind{ExecuteCommand, SyncedToEntraUser, SyncedToEntraDSUser, SyncedToEntraDSGroup, AddEntraDSGroupMember, SyncEntraDSUsers, AZRoleApprover}
+ return []graph.Kind{ExecuteCommand, ManageEntraDS, SyncedToEntraUser, SyncedToEntraDSUser, SyncedToEntraDSGroup, AddEntraDSGroupMember, EntraDSFor, ManageEntraDSSync, ManageEntraDSSyncFilter, AZRoleApprover}
}
func NodeKinds() []graph.Kind {
return []graph.Kind{Entity, VMScaleSet, App, Role, Device, FunctionApp, EntraDS, Group, KeyVault, ManagementGroup, ResourceGroup, ServicePrincipal, Subscription, Tenant, User, VM, ManagedCluster, ContainerRegistry, WebApp, LogicApp, AutomationAccount, FederatedIdentityCredential}
diff --git a/packages/go/graphschema/azure/azure_test.go b/packages/go/graphschema/azure/azure_test.go
new file mode 100644
index 000000000000..4a6359ac8717
--- /dev/null
+++ b/packages/go/graphschema/azure/azure_test.go
@@ -0,0 +1,51 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package azure_test
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/specterops/bloodhound/packages/go/graphschema/azure"
+ "github.com/specterops/dawgs/graph"
+)
+
+func TestEntraDSRelationshipTraversability(t *testing.T) {
+ for _, relationship := range []struct {
+ kind graph.Kind
+ traversable bool
+ control bool
+ }{
+ {kind: azure.EntraDSContributor, traversable: false, control: false},
+ {kind: azure.ManageEntraDS, traversable: true, control: true},
+ {kind: azure.EntraDSFor, traversable: false, control: false},
+ {kind: azure.ManageEntraDSSync, traversable: true, control: false},
+ {kind: azure.ManageEntraDSSyncFilter, traversable: true, control: false},
+ } {
+ if !slices.Contains(azure.Relationships(), relationship.kind) {
+ t.Errorf("%s must remain a recognized relationship kind", relationship.kind)
+ }
+
+ if actual := slices.Contains(azure.ControlRelationships(), relationship.kind); actual != relationship.control {
+ t.Errorf("%s control relationship status: got %t, want %t", relationship.kind, actual, relationship.control)
+ }
+
+ if actual := slices.Contains(azure.PathfindingRelationships(), relationship.kind); actual != relationship.traversable {
+ t.Errorf("%s pathfinding relationship status: got %t, want %t", relationship.kind, actual, relationship.traversable)
+ }
+ }
+}
diff --git a/packages/go/graphschema/common/common.go b/packages/go/graphschema/common/common.go
index 64a344219ff7..d8ff576da984 100644
--- a/packages/go/graphschema/common/common.go
+++ b/packages/go/graphschema/common/common.go
@@ -40,10 +40,10 @@ func NodeKinds() []graph.Kind {
return []graph.Kind{MigrationData}
}
func InboundRelationshipKinds() []graph.Kind {
- return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, azure.AvereContributor, azure.Contributor, azure.EntraDSContributor, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.SyncEntraDSUsers, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
+ return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, azure.AvereContributor, azure.Contributor, azure.ManageEntraDS, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.ManageEntraDSSync, azure.ManageEntraDSSyncFilter, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
}
func OutboundRelationshipKinds() []graph.Kind {
- return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, ad.DCFor, azure.AvereContributor, azure.Contributor, azure.EntraDSContributor, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.SyncEntraDSUsers, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
+ return []graph.Kind{ad.Owns, ad.GenericAll, ad.GenericWrite, ad.WriteOwner, ad.WriteDACL, ad.MemberOf, ad.ForceChangePassword, ad.AllExtendedRights, ad.AddMember, ad.HasSession, ad.GPLink, ad.AllowedToDelegate, ad.CoerceToTGT, ad.AllowedToAct, ad.AdminTo, ad.CanPSRemote, ad.CanRDP, ad.ExecuteDCOM, ad.HasSIDHistory, ad.AddSelf, ad.DCSync, ad.ReadLAPSPassword, ad.ReadGMSAPassword, ad.DumpSMSAPassword, ad.SQLAdmin, ad.AddAllowedToAct, ad.WriteSPN, ad.AddKeyCredentialLink, ad.SyncLAPSPassword, ad.WriteAccountRestrictions, ad.WriteGPLink, ad.GoldenCert, ad.ADCSESC1, ad.ADCSESC3, ad.ADCSESC4, ad.ADCSESC6a, ad.ADCSESC6b, ad.ADCSESC9a, ad.ADCSESC9b, ad.ADCSESC10a, ad.ADCSESC10b, ad.ADCSESC13, ad.SyncedToADUser, ad.CoerceAndRelayNTLMToSMB, ad.CoerceAndRelayNTLMToADCS, ad.WriteOwnerLimitedRights, ad.OwnsLimitedRights, ad.ClaimSpecialIdentity, ad.CoerceAndRelayNTLMToLDAP, ad.CoerceAndRelayNTLMToLDAPS, ad.ContainsIdentity, ad.PropagatesACEsTo, ad.GPOAppliesTo, ad.CanApplyGPO, ad.HasTrustKeys, ad.WriteAltSecurityIdentities, ad.WritePublicInformation, ad.ManageCA, ad.ManageCertificates, ad.Contains, ad.DCFor, azure.AvereContributor, azure.Contributor, azure.ManageEntraDS, azure.GetCertificates, azure.GetKeys, azure.GetSecrets, azure.HasRole, azure.MemberOf, azure.Owner, azure.RunsAs, azure.VMContributor, azure.AutomationContributor, azure.KeyVaultContributor, azure.VMAdminLogin, azure.AddMembers, azure.AddSecret, azure.ExecuteCommand, azure.GlobalAdmin, azure.PrivilegedAuthAdmin, azure.Grant, azure.GrantSelf, azure.PrivilegedRoleAdmin, azure.ResetPassword, azure.UserAccessAdministrator, azure.Owns, azure.CloudAppAdmin, azure.AppAdmin, azure.AddOwner, azure.ManagedIdentity, azure.AKSContributor, azure.NodeResourceGroup, azure.WebsiteContributor, azure.LogicAppContributor, azure.AZMGAddMember, azure.AZMGAddOwner, azure.AZMGAddSecret, azure.AZMGGrantAppRoles, azure.AZMGGrantRole, azure.SyncedToEntraUser, azure.SyncedToEntraDSUser, azure.AddEntraDSGroupMember, azure.ManageEntraDSSync, azure.ManageEntraDSSyncFilter, azure.AZRoleEligible, azure.AZRoleApprover, azure.Contains, azure.AZAuthenticatesTo}
}
type Property string
diff --git a/packages/go/schemagen/generator/typescript.go b/packages/go/schemagen/generator/typescript.go
index c17e47a4c42c..00c5c3d01ec6 100644
--- a/packages/go/schemagen/generator/typescript.go
+++ b/packages/go/schemagen/generator/typescript.go
@@ -111,8 +111,6 @@ func GenerateTypeScriptActiveDirectory(root tsgen.File, schema model.ActiveDirec
GenerateTypeScriptStringEnum(root, "ActiveDirectoryRelationshipKind", schema.RelationshipKinds)
GenerateTypeScriptUnionType(root, "ActiveDirectoryKind", unionKinds...)
- GenerateTypeScriptArray(root, "EdgeCompositionRelationships", schema.EdgeCompositionRelationships)
-
GenerateTypeScriptStringEnum(root, "ActiveDirectoryKindProperties", schema.Properties)
GenerateTypeScriptPathfindingEdgesFn(root, "ActiveDirectoryPathfindingEdges", "ActiveDirectoryRelationshipKind", schema.PathfindingRelationships)
diff --git a/packages/go/schemagen/model/schema.go b/packages/go/schemagen/model/schema.go
index 1ca97a088895..7564e0d57764 100644
--- a/packages/go/schemagen/model/schema.go
+++ b/packages/go/schemagen/model/schema.go
@@ -56,6 +56,7 @@ type Azure struct {
ControlRelationshipKinds []StringEnum
ExecutionPrivilegeKinds []StringEnum
PathfindingRelationships []StringEnum
+ EdgeCompositionRelationships []StringEnum
PostProcessedRelationships []StringEnum
}
diff --git a/packages/javascript/bh-shared-ui/src/graphSchema.ts b/packages/javascript/bh-shared-ui/src/graphSchema.ts
index 56ad3fa32686..cb1eb91a4f9c 100644
--- a/packages/javascript/bh-shared-ui/src/graphSchema.ts
+++ b/packages/javascript/bh-shared-ui/src/graphSchema.ts
@@ -342,25 +342,6 @@ export function ActiveDirectoryRelationshipKindToDisplay(value: ActiveDirectoryR
}
}
export type ActiveDirectoryKind = ActiveDirectoryNodeKind | ActiveDirectoryRelationshipKind;
-export const EdgeCompositionRelationships = [
- 'GoldenCert',
- 'ADCSESC1',
- 'ADCSESC3',
- 'ADCSESC4',
- 'ADCSESC6a',
- 'ADCSESC6b',
- 'ADCSESC9a',
- 'ADCSESC9b',
- 'ADCSESC10a',
- 'ADCSESC10b',
- 'ADCSESC13',
- 'CoerceAndRelayNTLMToSMB',
- 'CoerceAndRelayNTLMToADCS',
- 'CoerceAndRelayNTLMToLDAP',
- 'CoerceAndRelayNTLMToLDAPS',
- 'GPOAppliesTo',
- 'CanApplyGPO',
-];
export enum ActiveDirectoryKindProperties {
AdminCount = 'admincount',
CASecurityCollected = 'casecuritycollected',
@@ -1004,6 +985,7 @@ export enum AzureRelationshipKind {
Contains = 'AZContains',
Contributor = 'AZContributor',
EntraDSContributor = 'AZEntraDSContributor',
+ ManageEntraDS = 'AZManageEntraDS',
GetCertificates = 'AZGetCertificates',
GetKeys = 'AZGetKeys',
GetSecrets = 'AZGetSecrets',
@@ -1051,7 +1033,9 @@ export enum AzureRelationshipKind {
SyncedToEntraDSUser = 'SyncedToEntraDSUser',
SyncedToEntraDSGroup = 'SyncedToEntraDSGroup',
AddEntraDSGroupMember = 'AddEntraDSGroupMember',
- SyncEntraDSUsers = 'SyncEntraDSUsers',
+ EntraDSFor = 'EntraDSFor',
+ ManageEntraDSSync = 'ManageEntraDSSync',
+ ManageEntraDSSyncFilter = 'ManageEntraDSSyncFilter',
AZRoleEligible = 'AZRoleEligible',
AZRoleApprover = 'AZRoleApprover',
AZAuthenticatesTo = 'AZAuthenticatesTo',
@@ -1066,6 +1050,8 @@ export function AzureRelationshipKindToDisplay(value: AzureRelationshipKind): st
return 'Contributor';
case AzureRelationshipKind.EntraDSContributor:
return 'EntraDSContributor';
+ case AzureRelationshipKind.ManageEntraDS:
+ return 'ManageEntraDS';
case AzureRelationshipKind.GetCertificates:
return 'GetCertificates';
case AzureRelationshipKind.GetKeys:
@@ -1160,8 +1146,12 @@ export function AzureRelationshipKindToDisplay(value: AzureRelationshipKind): st
return 'SyncedToEntraDSGroup';
case AzureRelationshipKind.AddEntraDSGroupMember:
return 'AddEntraDSGroupMember';
- case AzureRelationshipKind.SyncEntraDSUsers:
- return 'SyncEntraDSUsers';
+ case AzureRelationshipKind.EntraDSFor:
+ return 'EntraDSFor';
+ case AzureRelationshipKind.ManageEntraDSSync:
+ return 'ManageEntraDSSync';
+ case AzureRelationshipKind.ManageEntraDSSyncFilter:
+ return 'ManageEntraDSSyncFilter';
case AzureRelationshipKind.AZRoleEligible:
return 'AZRoleEligible';
case AzureRelationshipKind.AZRoleApprover:
@@ -1220,21 +1210,21 @@ export enum AzureKindProperties {
FederatedIdentityCredentialAppID = 'federatedidentitycredentialappid',
DomainName = 'domainname',
DomainConfigurationType = 'domainconfigurationtype',
- FilteredSync = 'filteredsync',
+ FilteredSyncEnabled = 'filteredsyncenabled',
SyncScope = 'syncscope',
SyncApplicationID = 'syncapplicationid',
- NTLMV1 = 'ntlmv1',
- TLSV1 = 'tlsv1',
- SyncNTLMPasswords = 'syncntlmpasswords',
- SyncKerberosPasswords = 'synckerberospasswords',
- SyncOnPremPasswords = 'synconprempasswords',
- KerberosRC4Encryption = 'kerberosrc4encryption',
- KerberosArmoring = 'kerberosarmoring',
- LDAPSigning = 'ldapsigning',
- ChannelBinding = 'channelbinding',
- SyncOnPremSAMAccountName = 'synconpremsamaccountname',
- LDAPS = 'ldaps',
- LDAPSExternalAccess = 'ldapsexternalaccess',
+ NTLMV1Enabled = 'ntlmv1enabled',
+ TLSV1Enabled = 'tlsv1enabled',
+ SyncNTLMPasswordsEnabled = 'syncntlmpasswordsenabled',
+ SyncKerberosPasswordsEnabled = 'synckerberospasswordsenabled',
+ SyncOnPremPasswordsEnabled = 'synconprempasswordsenabled',
+ KerberosRC4EncryptionEnabled = 'kerberosrc4encryptionenabled',
+ KerberosArmoringEnabled = 'kerberosarmoringenabled',
+ LDAPSigningEnabled = 'ldapsigningenabled',
+ ChannelBindingEnabled = 'channelbindingenabled',
+ SyncOnPremSAMAccountNameEnabled = 'synconpremsamaccountnameenabled',
+ LDAPSEnabled = 'ldapsenabled',
+ LDAPSExternalAccessEnabled = 'ldapsexternalaccessenabled',
}
export function AzureKindPropertiesToDisplay(value: AzureKindProperties): string | undefined {
switch (value) {
@@ -1330,36 +1320,36 @@ export function AzureKindPropertiesToDisplay(value: AzureKindProperties): string
return 'Domain Name';
case AzureKindProperties.DomainConfigurationType:
return 'Domain Configuration Type';
- case AzureKindProperties.FilteredSync:
- return 'Filtered Sync';
+ case AzureKindProperties.FilteredSyncEnabled:
+ return 'Filtered Sync Enabled';
case AzureKindProperties.SyncScope:
return 'Sync Scope';
case AzureKindProperties.SyncApplicationID:
return 'Sync Application ID';
- case AzureKindProperties.NTLMV1:
- return 'NTLM V1';
- case AzureKindProperties.TLSV1:
- return 'TLS V1';
- case AzureKindProperties.SyncNTLMPasswords:
- return 'Sync NTLM Passwords';
- case AzureKindProperties.SyncKerberosPasswords:
- return 'Sync Kerberos Passwords';
- case AzureKindProperties.SyncOnPremPasswords:
- return 'Sync On-Premises Passwords';
- case AzureKindProperties.KerberosRC4Encryption:
- return 'Kerberos RC4 Encryption';
- case AzureKindProperties.KerberosArmoring:
- return 'Kerberos Armoring';
- case AzureKindProperties.LDAPSigning:
- return 'LDAP Signing';
- case AzureKindProperties.ChannelBinding:
- return 'Channel Binding';
- case AzureKindProperties.SyncOnPremSAMAccountName:
- return 'Sync On-Premises SAM Account Name';
- case AzureKindProperties.LDAPS:
- return 'Secure LDAP';
- case AzureKindProperties.LDAPSExternalAccess:
- return 'Secure LDAP External Access';
+ case AzureKindProperties.NTLMV1Enabled:
+ return 'NTLM V1 Enabled';
+ case AzureKindProperties.TLSV1Enabled:
+ return 'TLS V1 Enabled';
+ case AzureKindProperties.SyncNTLMPasswordsEnabled:
+ return 'Sync NTLM Passwords Enabled';
+ case AzureKindProperties.SyncKerberosPasswordsEnabled:
+ return 'Sync Kerberos Passwords Enabled';
+ case AzureKindProperties.SyncOnPremPasswordsEnabled:
+ return 'Sync On-Premises Passwords Enabled';
+ case AzureKindProperties.KerberosRC4EncryptionEnabled:
+ return 'Kerberos RC4 Encryption Enabled';
+ case AzureKindProperties.KerberosArmoringEnabled:
+ return 'Kerberos Armoring Enabled';
+ case AzureKindProperties.LDAPSigningEnabled:
+ return 'LDAP Signing Enabled';
+ case AzureKindProperties.ChannelBindingEnabled:
+ return 'Channel Binding Enabled';
+ case AzureKindProperties.SyncOnPremSAMAccountNameEnabled:
+ return 'Sync On-Premises SAM Account Name Enabled';
+ case AzureKindProperties.LDAPSEnabled:
+ return 'Secure LDAP Enabled';
+ case AzureKindProperties.LDAPSExternalAccessEnabled:
+ return 'Secure LDAP External Access Enabled';
default:
return undefined;
}
@@ -1368,7 +1358,7 @@ export function AzurePathfindingEdges(): AzureRelationshipKind[] {
return [
AzureRelationshipKind.AvereContributor,
AzureRelationshipKind.Contributor,
- AzureRelationshipKind.EntraDSContributor,
+ AzureRelationshipKind.ManageEntraDS,
AzureRelationshipKind.GetCertificates,
AzureRelationshipKind.GetKeys,
AzureRelationshipKind.GetSecrets,
@@ -1407,13 +1397,36 @@ export function AzurePathfindingEdges(): AzureRelationshipKind[] {
AzureRelationshipKind.SyncedToEntraUser,
AzureRelationshipKind.SyncedToEntraDSUser,
AzureRelationshipKind.AddEntraDSGroupMember,
- AzureRelationshipKind.SyncEntraDSUsers,
+ AzureRelationshipKind.ManageEntraDSSync,
+ AzureRelationshipKind.ManageEntraDSSyncFilter,
AzureRelationshipKind.AZRoleEligible,
AzureRelationshipKind.AZRoleApprover,
AzureRelationshipKind.Contains,
AzureRelationshipKind.AZAuthenticatesTo,
];
}
+export const EdgeCompositionRelationships = [
+ 'GoldenCert',
+ 'ADCSESC1',
+ 'ADCSESC3',
+ 'ADCSESC4',
+ 'ADCSESC6a',
+ 'ADCSESC6b',
+ 'ADCSESC9a',
+ 'ADCSESC9b',
+ 'ADCSESC10a',
+ 'ADCSESC10b',
+ 'ADCSESC13',
+ 'CoerceAndRelayNTLMToSMB',
+ 'CoerceAndRelayNTLMToADCS',
+ 'CoerceAndRelayNTLMToLDAP',
+ 'CoerceAndRelayNTLMToLDAPS',
+ 'GPOAppliesTo',
+ 'CanApplyGPO',
+ 'AZManageEntraDS',
+ 'AddEntraDSGroupMember',
+ 'ManageEntraDSSync',
+];
export enum CommonNodeKind {
MigrationData = 'MigrationData',
}
diff --git a/schemas/valid_edges.json b/schemas/valid_edges.json
index 3676c0412e77..c42898570d22 100644
--- a/schemas/valid_edges.json
+++ b/schemas/valid_edges.json
@@ -1,4 +1,69 @@
[
+ {
+ "source": "AZResourceGroup",
+ "target": "AZEntraDS",
+ "edges": {
+ "ingest": [
+ "AZContains"
+ ],
+ "post": []
+ }
+ },
+ {
+ "source": "AZUser",
+ "target": "AZEntraDS",
+ "edges": {
+ "ingest": [
+ "AZContributor",
+ "AZEntraDSContributor",
+ "AZOwner",
+ "AZUserAccessAdministrator"
+ ],
+ "post": [
+ "AZManageEntraDS"
+ ]
+ }
+ },
+ {
+ "source": "AZGroup",
+ "target": "AZEntraDS",
+ "edges": {
+ "ingest": [
+ "AZContributor",
+ "AZEntraDSContributor",
+ "AZOwner",
+ "AZUserAccessAdministrator"
+ ],
+ "post": [
+ "AZManageEntraDS"
+ ]
+ }
+ },
+ {
+ "source": "AZServicePrincipal",
+ "target": "AZEntraDS",
+ "edges": {
+ "ingest": [
+ "AZContributor",
+ "AZEntraDSContributor",
+ "AZOwner",
+ "AZUserAccessAdministrator"
+ ],
+ "post": [
+ "AZManageEntraDS"
+ ]
+ }
+ },
+ {
+ "source": "AZEntraDS",
+ "target": "Domain",
+ "edges": {
+ "ingest": [],
+ "post": [
+ "EntraDSFor"
+ ]
+ }
+ },
{
"source": "AZUser",
"target": "User",
@@ -21,12 +86,22 @@
}
},
{
- "source": "AZEntraDS",
+ "source": "AZUser",
+ "target": "Group",
+ "edges": {
+ "ingest": [],
+ "post": [
+ "ManageEntraDSSync"
+ ]
+ }
+ },
+ {
+ "source": "AZGroup",
"target": "Group",
"edges": {
"ingest": [],
"post": [
- "SyncEntraDSUsers"
+ "ManageEntraDSSync"
]
}
},
@@ -36,7 +111,8 @@
"edges": {
"ingest": [],
"post": [
- "SyncEntraDSUsers"
+ "ManageEntraDSSync",
+ "ManageEntraDSSyncFilter"
]
}
},
From 2fceddc08cba426d20e3828fa5dd58623f80adc5 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Mon, 10 Aug 2026 16:24:07 +0200
Subject: [PATCH 11/18] feat(ui): add Entra DS searches
---
.../bh-shared-ui/src/commonSearches.test.ts | 9 ++++--
.../bh-shared-ui/src/commonSearchesAGI.ts | 29 +++++++++++++++++--
.../bh-shared-ui/src/commonSearchesAGT.ts | 29 +++++++++++++++++--
.../EdgeFilter/edgeCategories.tsx | 5 ++--
4 files changed, 63 insertions(+), 9 deletions(-)
diff --git a/packages/javascript/bh-shared-ui/src/commonSearches.test.ts b/packages/javascript/bh-shared-ui/src/commonSearches.test.ts
index 42c7f384551f..0dcbc0ec2587 100644
--- a/packages/javascript/bh-shared-ui/src/commonSearches.test.ts
+++ b/packages/javascript/bh-shared-ui/src/commonSearches.test.ts
@@ -25,8 +25,9 @@ import { CommonSearchType } from './types';
describe('common search list', () => {
const kindPattern = /:([^ )\n\]*]+)/gm;
+ const supportedRelationshipShortcuts = ['ALL_ATTACK_PATHS', 'AZ_ATTACK_PATHS'];
- test('the queries in the list only include nodes and edges that are defined in our schema', () => {
+ test('the queries in the list only include nodes, edges, and relationship shortcuts supported by BloodHound', () => {
CommonSearches.forEach((commonSearchType: CommonSearchType) => {
commonSearchType.queries.forEach((query) => {
const kinds = query.query.match(kindPattern);
@@ -47,9 +48,11 @@ describe('common search list', () => {
const isAZEdge = Object.values(AzureRelationshipKind).includes(
kind as AzureRelationshipKind
);
- const inSchema = isADNode || isADEdge || isAZNode || isAZEdge;
+ const isSupportedRelationshipShortcut = supportedRelationshipShortcuts.includes(kind);
+ const isSupported =
+ isADNode || isADEdge || isAZNode || isAZEdge || isSupportedRelationshipShortcut;
- expect(inSchema).toBeTruthy();
+ expect(isSupported).toBeTruthy();
});
});
}
diff --git a/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts b/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
index 93a5f56fd111..5ad1dfb1f355 100644
--- a/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
+++ b/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
@@ -357,6 +357,11 @@ RETURN p\nLIMIT 1000`,
subheader: 'General',
category: categoryAzure,
queries: [
+ {
+ name: 'Entra DS instances and their associated domains',
+ description: '',
+ query: `MATCH p=(:AZTenant)-[:AZContains*1..]->(:AZEntraDS)-[:EntraDSFor]->(:Domain)\nRETURN p\nLIMIT 1000`,
+ },
{
name: 'All Global Administrators',
description: '',
@@ -435,6 +440,11 @@ RETURN p\nLIMIT 1000`,
subheader: 'Azure Hygiene',
category: categoryAzure,
queries: [
+ {
+ name: 'Entra DS domains exposing Secure LDAP externally',
+ description: '',
+ query: `MATCH (domainService:AZEntraDS)\nWHERE domainService.ldapsenabled = true\nAND domainService.ldapsexternalaccessenabled = true\nRETURN domainService\nLIMIT 1000`,
+ },
{
name: 'Foreign principals in Tier Zero / High Value targets',
description: '',
@@ -466,6 +476,21 @@ RETURN p\nLIMIT 1000`,
subheader: 'Cross Platform Attack Paths',
category: categoryAzure,
queries: [
+ {
+ name: 'Principals with Entra DS synchronization control',
+ description: '',
+ query: `OPTIONAL MATCH broadSyncPath = (:AZBase)-[:ManageEntraDSSync]->(:Group)\n\nOPTIONAL MATCH filteredSyncPath = (app:AZApp)-[:AZRunsAs]->(sp:AZServicePrincipal)-[:ManageEntraDSSyncFilter]->(:Group)\nWHERE app.objectid = '2565BD9D-DA50-47D4-8B85-4C97F669DC36'\n\nOPTIONAL MATCH applicationControlPath = (app)<-[:AZ_ATTACK_PATHS]-(applicationController:AZBase)\nWHERE NOT (applicationController)-[:SyncedToEntraDSUser]->(:User)\n\nOPTIONAL MATCH servicePrincipalControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(servicePrincipalController:AZBase)\nWHERE NOT (servicePrincipalController)-[:SyncedToEntraDSUser]->(:User)\nAND (servicePrincipalController:AZUser OR servicePrincipalController:AZServicePrincipal)\n\nOPTIONAL MATCH roleControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(:AZRole)-[:AZRoleEligible|AZHasRole]-(roleAssignee:AZBase)\nWHERE NOT (roleAssignee)-[:SyncedToEntraDSUser]->(:User)\n\nRETURN broadSyncPath,filteredSyncPath,applicationControlPath,servicePrincipalControlPath,roleControlPath\nLIMIT 1000`,
+ },
+ {
+ name: 'Members of the Entra DS Administrators group',
+ description: '',
+ query: `MATCH p = (entraMember:AZBase)-[:AZMemberOf]->(entraGroup:AZGroup)-[:SyncedToEntraDSGroup]->(entraDSGroup:Group)<-[:MemberOf]-(entraDSMember:Base)\nWHERE toUpper(entraGroup.displayname) = 'AAD DC ADMINISTRATORS'\nRETURN p\nLIMIT 1000`,
+ },
+ {
+ name: 'Entra DS-synchronized principals that can add Entra DS group membership they do not hold',
+ description: '',
+ query: `MATCH p = (entraUser:AZUser)-[:AddEntraDSGroupMember]->(entraDSGroup:Group)\nWHERE NOT((entraUser)-[:SyncedToEntraDSUser]->(:User)-[:MemberOf]->(entraDSGroup))\nRETURN p\nLIMIT 1000`,
+ },
{
name: 'Entra Users synced from On-Prem Users added to Domain Admins group',
description: '',
@@ -484,12 +509,12 @@ RETURN p\nLIMIT 1000`,
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (direct)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZManageEntraDS|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (group delegated)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZManageEntraDS|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users that Own Entra Objects',
diff --git a/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts b/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
index 0e8772100e75..cf942942da74 100644
--- a/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
+++ b/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
@@ -357,6 +357,11 @@ RETURN p\nLIMIT 1000`,
subheader: 'General',
category: categoryAzure,
queries: [
+ {
+ name: 'Entra DS instances and their associated domains',
+ description: '',
+ query: `MATCH p=(:AZTenant)-[:AZContains*1..]->(:AZEntraDS)-[:EntraDSFor]->(:Domain)\nRETURN p\nLIMIT 1000`,
+ },
{
name: 'All Global Administrators',
description: '',
@@ -435,6 +440,11 @@ RETURN p\nLIMIT 1000`,
subheader: 'Azure Hygiene',
category: categoryAzure,
queries: [
+ {
+ name: 'Entra DS domains exposing Secure LDAP externally',
+ description: '',
+ query: `MATCH (domainService:AZEntraDS)\nWHERE domainService.ldapsenabled = true\nAND domainService.ldapsexternalaccessenabled = true\nRETURN domainService\nLIMIT 1000`,
+ },
{
name: 'Foreign principals in Tier Zero / High Value targets',
description: '',
@@ -466,6 +476,21 @@ RETURN p\nLIMIT 1000`,
subheader: 'Cross Platform Attack Paths',
category: categoryAzure,
queries: [
+ {
+ name: 'Principals with Entra DS synchronization control',
+ description: '',
+ query: `OPTIONAL MATCH broadSyncPath = (:AZBase)-[:ManageEntraDSSync]->(:Group)\n\nOPTIONAL MATCH filteredSyncPath = (app:AZApp)-[:AZRunsAs]->(sp:AZServicePrincipal)-[:ManageEntraDSSyncFilter]->(:Group)\nWHERE app.objectid = '2565BD9D-DA50-47D4-8B85-4C97F669DC36'\n\nOPTIONAL MATCH applicationControlPath = (app)<-[:AZ_ATTACK_PATHS]-(applicationController:AZBase)\nWHERE NOT (applicationController)-[:SyncedToEntraDSUser]->(:User)\n\nOPTIONAL MATCH servicePrincipalControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(servicePrincipalController:AZBase)\nWHERE NOT (servicePrincipalController)-[:SyncedToEntraDSUser]->(:User)\nAND (servicePrincipalController:AZUser OR servicePrincipalController:AZServicePrincipal)\n\nOPTIONAL MATCH roleControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(:AZRole)-[:AZRoleEligible|AZHasRole]-(roleAssignee:AZBase)\nWHERE NOT (roleAssignee)-[:SyncedToEntraDSUser]->(:User)\n\nRETURN broadSyncPath,filteredSyncPath,applicationControlPath,servicePrincipalControlPath,roleControlPath\nLIMIT 1000`,
+ },
+ {
+ name: 'Members of the Entra DS Administrators group',
+ description: '',
+ query: `MATCH p = (entraMember:AZBase)-[:AZMemberOf]->(entraGroup:AZGroup)-[:SyncedToEntraDSGroup]->(entraDSGroup:Group)<-[:MemberOf]-(entraDSMember:Base)\nWHERE toUpper(entraGroup.displayname) = 'AAD DC ADMINISTRATORS'\nRETURN p\nLIMIT 1000`,
+ },
+ {
+ name: 'Entra DS-synchronized principals that can add Entra DS group membership they do not hold',
+ description: '',
+ query: `MATCH p = (entraUser:AZUser)-[:AddEntraDSGroupMember]->(entraDSGroup:Group)\nWHERE NOT((entraUser)-[:SyncedToEntraDSUser]->(:User)-[:MemberOf]->(entraDSGroup))\nRETURN p\nLIMIT 1000`,
+ },
{
name: 'Entra Users synced from On-Prem Users added to Domain Admins group',
description: '',
@@ -484,12 +509,12 @@ RETURN p\nLIMIT 1000`,
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (direct)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZManageEntraDS|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users with Azure RM Roles (group delegated)',
description: '',
- query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZEntraDSContributor|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
+ query: `MATCH p = (:User)-[:SyncedToEntraUser]->(:AZUser)-[:AZMemberOf]->(:AZGroup)-[:AZOwner|AZUserAccessAdministrator|AZGetCertificates|AZGetKeys|AZGetSecrets|AZAvereContributor|AZKeyVaultContributor|AZContributor|AZManageEntraDS|AZVMAdminLogin|AZVMContributor|AZAKSContributor|AZAutomationContributor|AZLogicAppContributor|AZWebsiteContributor]->(:AZBase)\nRETURN p\nLIMIT 1000`,
},
{
name: 'On-Prem Users synced to Entra Users that Own Entra Objects',
diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
index 94c20c2686a2..35b5631f6538 100644
--- a/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/ExploreSearch/EdgeFilter/edgeCategories.tsx
@@ -207,6 +207,7 @@ export const BUILTIN_EDGE_CATEGORIES: Category[] = [
AzureRelationshipKind.KeyVaultContributor,
AzureRelationshipKind.Owner,
AzureRelationshipKind.Contributor,
+ AzureRelationshipKind.ManageEntraDS,
AzureRelationshipKind.UserAccessAdministrator,
AzureRelationshipKind.VMAdminLogin,
AzureRelationshipKind.VMContributor,
@@ -217,7 +218,6 @@ export const BUILTIN_EDGE_CATEGORIES: Category[] = [
edgeTypes: [
AzureRelationshipKind.AKSContributor,
AzureRelationshipKind.AutomationContributor,
- AzureRelationshipKind.EntraDSContributor,
AzureRelationshipKind.LogicAppContributor,
AzureRelationshipKind.WebsiteContributor,
],
@@ -228,7 +228,8 @@ export const BUILTIN_EDGE_CATEGORIES: Category[] = [
AzureRelationshipKind.SyncedToEntraUser,
AzureRelationshipKind.SyncedToEntraDSUser,
AzureRelationshipKind.AddEntraDSGroupMember,
- AzureRelationshipKind.SyncEntraDSUsers,
+ AzureRelationshipKind.ManageEntraDSSync,
+ AzureRelationshipKind.ManageEntraDSSyncFilter,
],
},
],
From e072469b6c6310c2dbbc2bd5fc3fe72d59f5f9b3 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Mon, 10 Aug 2026 16:24:29 +0200
Subject: [PATCH 12/18] fix(ui): avoid stale edge composition queries
---
.../useExploreGraph/useExploreGraph.test.tsx | 33 +++++++++++++++++--
.../hooks/useExploreGraph/useExploreGraph.tsx | 4 +--
2 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.test.tsx b/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.test.tsx
index 0879d775bf5b..e9b5498b118e 100644
--- a/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.test.tsx
+++ b/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.test.tsx
@@ -14,18 +14,47 @@
//
// SPDX-License-Identifier: Apache-2.0
-import { renderHook } from '@testing-library/react';
import { RelationshipDetailsWithInfo } from 'js-client-library';
+import { renderHook } from '../../test-utils';
+import { apiClient } from '../../utils/api';
import { ExploreQueryParams } from '../useExploreParams';
-import { exploreGraphQueryFactory, useUserSettings } from './useExploreGraph';
+import { exploreGraphQueryFactory, useExploreGraph, useUserSettings } from './useExploreGraph';
const mockUseTimeoutLimitConfiguration = vi.fn();
+const mockUseGraphItem = vi.hoisted(() => vi.fn());
vi.mock('../useConfiguration', () => ({
useTimeoutLimitConfiguration: () => mockUseTimeoutLimitConfiguration(),
}));
+vi.mock('../useGraphItem', () => ({
+ isRelationshipResponse: (data: Record) => 'relationship_id' in data,
+ useGraphItem: mockUseGraphItem,
+}));
+
describe('useExploreGraph', () => {
+ it('does not run a composition query with previous relationship data', () => {
+ mockUseGraphItem.mockReturnValue({
+ data: {
+ relationship_id: 99,
+ kind: { relationship_kind_id: 1, name: 'ManageEntraDSSync' },
+ source_node_id: 1,
+ target_node_id: 2,
+ properties: {},
+ },
+ isPreviousData: true,
+ });
+ const getEdgeCompositionSpy = vi.spyOn(apiClient, 'getEdgeComposition');
+
+ const { result } = renderHook(() => useExploreGraph(), {
+ route: '/?searchType=composition&relationshipQueryItemId=rel_99',
+ });
+
+ expect(result.current.isFetching).toBe(false);
+ expect(getEdgeCompositionSpy).not.toHaveBeenCalled();
+ getEdgeCompositionSpy.mockRestore();
+ });
+
describe('exploreGraphQueryFactory', () => {
it('returns {enabled: false} if there is not a match on the switch statement', () => {
const paramOptions = {
diff --git a/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.tsx b/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.tsx
index 5ce6f455f63b..09c7d794ee1f 100644
--- a/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.tsx
+++ b/packages/javascript/bh-shared-ui/src/hooks/useExploreGraph/useExploreGraph.tsx
@@ -68,8 +68,8 @@ export const useExploreGraph = (options: ExploreGraphQueryOptions = {}) => {
const { addNotification } = useNotifications();
const userSettings = useUserSettings();
- const { data } = useGraphItem(params.relationshipQueryItemId);
- const relationshipDetails = data && isRelationshipResponse(data) ? data : undefined;
+ const { data, isPreviousData } = useGraphItem(params.relationshipQueryItemId);
+ const relationshipDetails = data && !isPreviousData && isRelationshipResponse(data) ? data : undefined;
const query = exploreGraphQueryFactory(params, { userSettings, relationshipDetails });
From 3700ff2580a434c7ab76efa13a7b7443130c5a21 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Mon, 10 Aug 2026 16:25:08 +0200
Subject: [PATCH 13/18] feat(ui): add Entra DS help text
---
.../HelpTexts/AZContributor/Abuse.tsx | 10 +--
.../AZDomainServicesContributor/Abuse.tsx | 39 ----------
.../AZEntraDSContributor.tsx} | 4 +-
.../Abuse.tsx} | 12 +--
.../General.tsx | 6 +-
.../Opsec.tsx | 0
.../References.tsx | 4 +
.../AZManageEntraDS/AZManageEntraDS.tsx | 68 +++++++++++++++++
.../HelpTexts/AZManageEntraDS/Composition.tsx | 52 +++++++++++++
.../components/HelpTexts/AZOwner/General.tsx | 11 +--
.../Composition.test.tsx | 9 +--
.../AddEntraDSGroupMember/Composition.tsx | 22 +-----
.../AddEntraDSGroupMember/General.tsx | 15 ++--
.../AddEntraDSGroupMember/LinuxAbuse.tsx | 45 +++++++----
.../AddEntraDSGroupMember/References.tsx | 4 +
.../AddEntraDSGroupMember/WindowsAbuse.tsx | 45 +++++++----
.../HelpTexts/EntraDSFor/EntraDSFor.tsx | 19 +++++
.../ManageEntraDSSync/Composition.test.tsx | 55 ++++++++++++++
.../ManageEntraDSSync/Composition.tsx | 51 +++++++++++++
.../ManageEntraDSSync/ManageEntraDSSync.tsx | 65 ++++++++++++++++
.../ManageEntraDSSyncFilter.tsx | 76 +++++++++++++++++++
.../HelpTexts/SyncEntraDSUsers/General.tsx | 54 -------------
.../HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx | 38 ----------
.../HelpTexts/SyncEntraDSUsers/References.tsx | 56 --------------
.../SyncEntraDSUsers/SyncEntraDSUsers.tsx | 31 --------
.../SyncEntraDSUsers/WindowsAbuse.tsx | 38 ----------
.../SyncedToEntraDSGroup/References.tsx | 4 +
.../SyncedToEntraDSUser/LinuxAbuse.tsx | 29 +++++--
.../SyncedToEntraDSUser/References.tsx | 4 +
.../SyncedToEntraDSUser/WindowsAbuse.tsx | 29 +++++--
.../src/components/HelpTexts/index.tsx | 14 +++-
.../Explore/EdgeInfo/EdgeInfoContent.test.tsx | 27 +++++++
32 files changed, 578 insertions(+), 358 deletions(-)
delete mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/Abuse.tsx
rename packages/javascript/bh-shared-ui/src/components/HelpTexts/{AZDomainServicesContributor/AZDomainServicesContributor.tsx => AZEntraDSContributor/AZEntraDSContributor.tsx} (91%)
rename packages/javascript/bh-shared-ui/src/components/HelpTexts/{SyncEntraDSUsers/Opsec.tsx => AZEntraDSContributor/Abuse.tsx} (60%)
rename packages/javascript/bh-shared-ui/src/components/HelpTexts/{AZDomainServicesContributor => AZEntraDSContributor}/General.tsx (72%)
rename packages/javascript/bh-shared-ui/src/components/HelpTexts/{AZDomainServicesContributor => AZEntraDSContributor}/Opsec.tsx (100%)
rename packages/javascript/bh-shared-ui/src/components/HelpTexts/{AZDomainServicesContributor => AZEntraDSContributor}/References.tsx (89%)
create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.test.tsx
create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.tsx
create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
delete mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/General.tsx
delete mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx
delete mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/References.tsx
delete mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/SyncEntraDSUsers.tsx
delete mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/WindowsAbuse.tsx
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx
index 78dc8a2511af..88e7b9bb889e 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx
@@ -36,11 +36,11 @@ const Abuse: FC = () => {
Virtual Machine: Run SYSTEM commands on the VM
- Microsoft Entra Domain Services: Modify the managed domain's configurable security and
- synchronization settings. This can include enabling legacy authentication protocols, weakening LDAP
- protections, exposing Secure LDAP, or changing synchronization behavior. Microsoft documents additional
- Entra roles for some of these settings, so those routes depend on the target service's authorization
- checks.
+ Microsoft Entra Domain Services: Contributor supplies the Azure Resource Manager
+ portion of managed-domain configuration authorization. Live validation required the same effective
+ principal to also have Application Administrator and Groups Administrator before changing a
+ representative security setting, syncScope, or filteredSync. BloodHound represents that conjunction with
+ the post-processed AZManageEntraDS edge.
Resource Group: NOT abusable, and not collected by AzureHound
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/Abuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/Abuse.tsx
deleted file mode 100644
index 23a6cb12c6df..000000000000
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/Abuse.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright 2026 Specter Ops, Inc.
-//
-// Licensed under the Apache License, Version 2.0
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import { Typography } from 'doodle-ui';
-import { FC } from 'react';
-
-const Abuse: FC = () => {
- return (
- <>
-
- The role permits Azure Resource Manager operations against the target managed domain. Potential abuse
- includes changing synchronization eligibility, weakening NTLM, Kerberos, LDAP signing, or channel
- binding settings, and changing Secure LDAP exposure or configuration.
-
-
-
- Microsoft documents Application Administrator and Groups Administrator Entra roles as additional
- prerequisites for changing managed-domain security settings and synchronization scope. RBAC-only
- behavior has not been validated, so those specific abuse routes depend on the service's additional
- authorization checks.
-
- >
- );
-};
-
-export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/AZDomainServicesContributor.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/AZEntraDSContributor.tsx
similarity index 91%
rename from packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/AZDomainServicesContributor.tsx
rename to packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/AZEntraDSContributor.tsx
index 1203e243f9f7..9671abbf9cc3 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/AZDomainServicesContributor.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/AZEntraDSContributor.tsx
@@ -19,11 +19,11 @@ import General from './General';
import Opsec from './Opsec';
import References from './References';
-const AZDomainServicesContributor = {
+const AZEntraDSContributor = {
general: General,
abuse: Abuse,
opsec: Opsec,
references: References,
};
-export default AZDomainServicesContributor;
+export default AZEntraDSContributor;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/Opsec.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Abuse.tsx
similarity index 60%
rename from packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/Opsec.tsx
rename to packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Abuse.tsx
index 42bdc86eddea..0b0f5ca1e443 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/Opsec.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Abuse.tsx
@@ -17,15 +17,15 @@
import { Typography } from 'doodle-ui';
import { FC } from 'react';
-const Opsec: FC = () => {
+const Abuse: FC = () => {
return (
- ARM updates to the managed domain synchronization settings, app-role assignments on the Domain Controller
- Services service principal, and Entra group membership changes generate Microsoft Entra audit activity.
- Changing the scope triggers an Entra Domain Services resynchronization, and subsequent authentication may
- generate Windows logon events and Azure Monitor diagnostic records when those logs are enabled.
+ This assignment supplies the Azure Resource Manager portion of managed-domain configuration changes. Live
+ validation found it insufficient by itself and with either Application Administrator or Groups Administrator
+ alone. When the same effective principal also has both Entra roles, BloodHound creates the traversable
+ AZManageEntraDS edge.
);
};
-export default Opsec;
+export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
similarity index 72%
rename from packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/General.tsx
rename to packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
index e246c4dadaec..9210aa2d876d 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
@@ -20,9 +20,9 @@ import { FC } from 'react';
const General: FC = () => {
return (
- AZDomainServicesContributor means an Entra principal has a direct assignment of the built-in Domain Services
- Contributor Azure Resource Manager role on the target AZDomainService. The role grants broad management of
- the managed-domain resource through Microsoft.AAD/domainServices/*.
+ AZEntraDSContributor records an assignment of the built-in Domain Services Contributor Azure Resource
+ Manager role on the target AZEntraDS resource. It is raw authorization evidence and is not independently
+ traversable.
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/Opsec.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Opsec.tsx
similarity index 100%
rename from packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/Opsec.tsx
rename to packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Opsec.tsx
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/References.tsx
similarity index 89%
rename from packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/References.tsx
rename to packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/References.tsx
index a8286804b08a..e081a5ebdf8e 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZDomainServicesContributor/References.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/References.tsx
@@ -47,6 +47,10 @@ const References: FC = () => {
href='https://learn.microsoft.com/en-us/entra/identity/domain-services/scoped-synchronization'>
Configure scoped synchronization
+
+
+ MITRE ATT&CK T1484: Domain or Tenant Policy Modification
+
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
new file mode 100644
index 000000000000..8b96da54f70b
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
@@ -0,0 +1,68 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+import Opsec from '../AZEntraDSContributor/Opsec';
+import References from '../AZEntraDSContributor/References';
+import Composition from './Composition';
+
+const General: FC = () => (
+
+ AZManageEntraDS is a post-processed, traversable relationship. The same effective principal has Contributor or
+ raw AZEntraDSContributor over the managed domain and also has Application Administrator and Groups Administrator
+ in the tenant.
+
+);
+
+const Abuse: FC = () => (
+
+ The source can change the Microsoft Entra Domain Services (Entra DS) managed domain's security configuration and
+ broad synchronization boundary. Live validation confirmed changes to a representative security setting,{' '}
+ syncScope, and filteredSync only when all three authorization components were present.
+
+
+ Obtain an Azure Resource Manager access token and all three authorization components represented by the
+ edge: AZContributor or AZEntraDSContributor, Application Administrator, and Groups Administrator.
+
+
+ Read the Entra DS resource and retain its location and current nested settings:
+
+ GET https://management.azure.com/{'{resource-id}'}?api-version=2025-06-01
+
+
+
+ Choose a supported management-plane change. To change identity synchronization, see ManageEntraDSSync.
+
+
+ Submit the change with:
+
+ PUT https://management.azure.com/{'{resource-id}'}?api-version=2025-06-01
+
+
+
+
+);
+
+const AZManageEntraDS = {
+ general: General,
+ abuse: Abuse,
+ opsec: Opsec,
+ references: References,
+ composition: Composition,
+};
+
+export default AZManageEntraDS;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
new file mode 100644
index 000000000000..336addc4153c
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
@@ -0,0 +1,52 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Alert, Box, Skeleton } from '@mui/material';
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+import { EdgeInfoProps } from '..';
+import { EdgeInfoItems, useEdgeInfoItems } from '../../../hooks/useExploreGraph/useEdgeInfoItems';
+import VirtualizedNodeList from '../../VirtualizedNodeList';
+
+const Composition: FC = ({ sourceDBId, targetDBId, edgeName }) => {
+ const { isLoading, isError, nodesArray } = useEdgeInfoItems({
+ sourceDBId,
+ targetDBId,
+ edgeName,
+ type: EdgeInfoItems['composition'],
+ });
+
+ return (
+ <>
+
+ The relationship combines the source principal's effective Contributor or Domain Services Contributor
+ path to this managed domain with its Application Administrator and Groups Administrator role paths in
+ the tenant. All three permission components must apply to the same source principal.
+
+
+ {isLoading ? (
+
+ ) : isError ? (
+ Couldn't load edge composition
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export default Composition;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZOwner/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZOwner/General.tsx
index bc1c8bb9bc33..067500819d1e 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZOwner/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZOwner/General.tsx
@@ -39,12 +39,9 @@ const AZVMLink = (
);
-const AZDomainServiceLink = (
-
- AZDomainService
+const AZEntraDSLink = (
+
+ AZEntraDS
);
@@ -56,7 +53,7 @@ const General: FC = () => {
AZOwner targets resources in AzureRM (for example {AZResourceGroupLink}, {AZSubscriptionLink}, {AZVMLink},
- and {AZDomainServiceLink}) through a role assignment called “Owner”.
+ and {AZEntraDSLink}) through a role assignment called “Owner”.
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx
index 43ff64adfb88..e7150ff20be6 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx
@@ -61,7 +61,7 @@ const CompositionGraphProbe = () => {
};
describe('AddEntraDSGroupMember Composition', () => {
- it('uses the tuple-form relationship key required by the composition graph query', async () => {
+ it('preserves the selected relationship ID used by the composition graph query', async () => {
render(
<>
@@ -72,11 +72,10 @@ describe('AddEntraDSGroupMember Composition', () => {
}
);
+ expect(await screen.findByText('graph-loaded')).toBeInTheDocument();
await waitFor(() => {
- expect(window.location.search).toContain(
- 'relationshipQueryItemId=rel_1_AddEntraDSGroupMember_2'
- );
+ expect(window.location.search).toContain('relationshipQueryItemId=rel_99');
});
- expect(await screen.findByText('graph-loaded')).toBeInTheDocument();
+ expect(window.location.search).not.toContain('relationshipQueryItemId=rel_1_AddEntraDSGroupMember_2');
});
});
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx
index 443f892b7075..c15dbac3b7e7 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.tsx
@@ -16,15 +16,12 @@
import { Alert, Box, Skeleton } from '@mui/material';
import { Typography } from 'doodle-ui';
-import { FC, useEffect } from 'react';
+import { FC } from 'react';
import { EdgeInfoProps } from '..';
import { EdgeInfoItems, useEdgeInfoItems } from '../../../hooks/useExploreGraph/useEdgeInfoItems';
-import { useExploreParams } from '../../../hooks/useExploreParams';
-import { createRelItemId } from '../../../utils';
import VirtualizedNodeList from '../../VirtualizedNodeList';
const Composition: FC = ({ sourceDBId, targetDBId, edgeName }) => {
- const { relationshipQueryItemId, searchType, setExploreParams } = useExploreParams();
const { isLoading, isError, nodesArray } = useEdgeInfoItems({
sourceDBId,
targetDBId,
@@ -32,23 +29,6 @@ const Composition: FC = ({ sourceDBId, targetDBId, edgeName }) =>
type: EdgeInfoItems['composition'],
});
- useEffect(() => {
- if (searchType !== 'composition' || sourceDBId === undefined || targetDBId === undefined || !edgeName) {
- return;
- }
-
- // The composition graph query still consumes the tuple-form relationship key.
- const compositionRelationshipQueryItemId = createRelItemId(
- sourceDBId.toString(),
- edgeName,
- targetDBId.toString()
- );
-
- if (relationshipQueryItemId !== compositionRelationshipQueryItemId) {
- setExploreParams({ relationshipQueryItemId: compositionRelationshipQueryItemId }, { replace: true });
- }
- }, [edgeName, relationshipQueryItemId, searchType, setExploreParams, sourceDBId, targetDBId]);
-
return (
<>
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
index aa7b60485e06..ac3b63296ea3 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
@@ -21,19 +21,20 @@ const General: FC = () => {
return (
<>
- This relationship indicates that a synchronized Entra user can effectively add members to an Entra
- Domain Services group by controlling the corresponding synchronized Entra group.
+ This relationship indicates that a synchronized Entra user can effectively add or remove members from an
+ Entra Domain Services group by controlling the corresponding synchronized Entra group.
The relationship is composed from three conditions: the Entra user is synchronized to Entra Domain
- Services; the Entra user owns or can add members to an Entra group; and the Entra group is synchronized
- to an Entra Domain Services group.
+ Services; the Entra user owns or can add and remove members from an Entra group; and the Entra group is
+ synchronized to an Entra Domain Services group.
Because the Entra user already has a usable Entra Domain Services identity, they can add themselves or
- another controlled synchronized principal to the Entra group and wait for membership to synchronize into
- the Entra Domain Services group. This effectively grants the Entra user any privileges held by the Entra
- Domain Services group.
+ another controlled synchronized principal to the Entra group, remove existing members, and wait for the
+ membership change to synchronize into the Entra Domain Services group. Adding membership effectively
+ grants the Entra user any privileges held by the Entra Domain Services group; removing membership can
+ revoke those privileges from another principal.
Only direct membership in the source Entra group is synchronized. Nested Entra groups do not satisfy
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx
index d347f8fc061e..f054812bdba1 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.tsx
@@ -19,22 +19,35 @@ import { FC } from 'react';
const Abuse: FC = () => {
return (
- <>
-
- Using the Entra user's control over the Entra group, add the Entra user or another controlled
- synchronized principal as a direct member of the Entra group. Using the Microsoft Graph API, for example
- with a POST to the group's members reference:
-
-
- {
- 'curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" \\\n https://graph.microsoft.com/v1.0/groups//members/\\$ref \\\n -d \'{"@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/"}\''
- }
-
-
- After Entra Domain Services synchronizes the direct membership change, the principal becomes a member of
- the corresponding Entra Domain Services group and inherits its access within the managed domain.
-
- >
+
+
+
+ Add a user as a direct member of the Entra AZGroup that correlates to the
+ destination Microsoft Entra Domain Services (Entra DS) group. Nested group membership does not reach
+ Entra DS. Submit the following Microsoft Graph request:
+
+ {
+ 'POST https://graph.microsoft.com/v1.0/groups/{entra-group-object-id}/members/$ref\nContent-Type: application/json\n\n{\n "@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/{controlled-user-object-id}"\n}'
+ }
+
+ A successful request returns 204 No Content with no response body. The same request can
+ be sent with curl:
+
+ {
+ 'curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" \\\n https://graph.microsoft.com/v1.0/groups//members/\\$ref \\\n -d \'{"@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/"}\''
+ }
+
+
+
+ Wait for Entra DS to synchronize the membership and verify the destination group's direct{' '}
+ member value when LDAP read access is available.
+
+
+ Reauthenticate the controlled user to Entra DS so its logon session or Kerberos ticket contains the
+ newly synchronized group SID.
+
+
+
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx
index d4ca8e2bbf4d..79d1d954b1ee 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.tsx
@@ -27,6 +27,10 @@ const References: FC = () => {
label: 'Add members to a group using Microsoft Graph',
link: 'https://learn.microsoft.com/en-us/graph/api/group-post-members',
},
+ {
+ label: 'MITRE ATT&CK T1098.007: Account Manipulation - Additional Local or Domain Groups',
+ link: 'https://attack.mitre.org/techniques/T1098/007/',
+ },
];
return (
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx
index c221f96f366a..aed87a20b758 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.tsx
@@ -19,22 +19,35 @@ import { FC } from 'react';
const Abuse: FC = () => {
return (
- <>
-
- Using the Entra user's control over the Entra group, add the Entra user or another controlled
- synchronized principal as a direct member of the Entra group. In Microsoft Graph PowerShell this can be
- done with:
-
-
- {
- 'New-MgGroupMemberByRef -GroupId "" -OdataId "https://graph.microsoft.com/v1.0/directoryObjects/"'
- }
-
-
- After Entra Domain Services synchronizes the direct membership change, the principal becomes a member of
- the corresponding Entra Domain Services group and inherits its access within the managed domain.
-
- >
+
+
+
+ Add a user as a direct member of the Entra AZGroup that correlates to the
+ destination Microsoft Entra Domain Services (Entra DS) group. Nested group membership does not reach
+ Entra DS. Submit the following Microsoft Graph request:
+
+ {
+ 'POST https://graph.microsoft.com/v1.0/groups/{entra-group-object-id}/members/$ref\nContent-Type: application/json\n\n{\n "@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/{controlled-user-object-id}"\n}'
+ }
+
+ A successful request returns 204 No Content with no response body. The same request can
+ be sent with Microsoft Graph PowerShell:
+
+ {
+ 'New-MgGroupMemberByRef -GroupId "" -OdataId "https://graph.microsoft.com/v1.0/directoryObjects/"'
+ }
+
+
+
+ Wait for Entra DS to synchronize the membership and verify the destination group's direct{' '}
+ member value when LDAP read access is available.
+
+
+ Reauthenticate the controlled user to Entra DS so its logon session or Kerberos ticket contains the
+ newly synchronized group SID.
+
+
+
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
new file mode 100644
index 000000000000..7ed3d688db51
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
@@ -0,0 +1,19 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// SPDX-License-Identifier: Apache-2.0
+
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const General: FC = () => (
+
+ EntraDSFor is a non-traversable, post-processed correlation from an AZEntraDS resource to its managed AD Domain.
+ BloodHound requires matching normalized domain names and corroborates the domain SID through the tenant's
+ synchronized AAD DC Administrators group.
+
+);
+
+const EntraDSFor = { general: General };
+
+export default EntraDSFor;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.test.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.test.tsx
new file mode 100644
index 000000000000..e5144b25cd0a
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.test.tsx
@@ -0,0 +1,55 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// SPDX-License-Identifier: Apache-2.0
+
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import { useExploreGraph } from '../../../hooks/useExploreGraph/useExploreGraph';
+import { render, screen, waitFor } from '../../../test-utils';
+import Composition from './Composition';
+
+const server = setupServer(
+ rest.get('/api/v2/relationships/:relationshipId', (req, res, ctx) =>
+ res(
+ ctx.json({
+ data: {
+ relationship_id: Number(req.params.relationshipId),
+ kind: { relationship_kind_id: 1, name: 'ManageEntraDSSync' },
+ source_node_id: 1,
+ target_node_id: 2,
+ properties: {},
+ },
+ })
+ )
+ ),
+ rest.get('/api/v2/graphs/edge-composition', (_req, res, ctx) => res(ctx.json({ data: { nodes: {}, edges: [] } }))),
+ rest.get('/api/v2/config', (_req, res, ctx) => res(ctx.json({ data: [] })))
+);
+
+beforeAll(() => server.listen());
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const CompositionGraphProbe = () => {
+ const { data } = useExploreGraph();
+ return data ? graph-loaded : null;
+};
+
+describe('ManageEntraDSSync Composition', () => {
+ it('preserves the selected relationship ID used by the composition graph query', async () => {
+ render(
+ <>
+
+
+ >,
+ { route: '/?searchType=composition&relationshipQueryItemId=rel_99' }
+ );
+
+ expect(await screen.findByText('graph-loaded')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(window.location.search).toContain('relationshipQueryItemId=rel_99');
+ });
+ expect(window.location.search).not.toContain('relationshipQueryItemId=rel_1_ManageEntraDSSync_2');
+ });
+});
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.tsx
new file mode 100644
index 000000000000..3c587df9cd76
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/Composition.tsx
@@ -0,0 +1,51 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import { Alert, Box, Skeleton } from '@mui/material';
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+import { EdgeInfoProps } from '..';
+import { EdgeInfoItems, useEdgeInfoItems } from '../../../hooks/useExploreGraph/useEdgeInfoItems';
+import VirtualizedNodeList from '../../VirtualizedNodeList';
+
+const Composition: FC = ({ sourceDBId, targetDBId, edgeName }) => {
+ const { isLoading, isError, nodesArray } = useEdgeInfoItems({
+ sourceDBId,
+ targetDBId,
+ edgeName,
+ type: EdgeInfoItems['composition'],
+ });
+
+ return (
+ <>
+
+ The relationship is composed from AZManageEntraDS, EntraDSFor, and an AD containment path to Domain
+ Users. All three paths are required for the attack edge to exist.
+
+
+ {isLoading ? (
+
+ ) : isError ? (
+ Couldn't load edge composition
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export default Composition;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
new file mode 100644
index 000000000000..f6e7c0dce37d
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
@@ -0,0 +1,65 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// SPDX-License-Identifier: Apache-2.0
+
+import { Box, Link } from '@mui/material';
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+import Composition from './Composition';
+
+const General: FC = () => (
+
+ ManageEntraDSSync means the source can broaden the synchronization boundary of the correlated Microsoft Entra
+ Domain Services (Entra DS) domain and cause eligible Entra users to materialize with Domain Users access. It is
+ emitted from an AZManageEntraDS principal regardless of the current filteredSync and syncScope settings.
+
+);
+
+const Abuse: FC = () => (
+
+
+
+ Read the managed domain's current filteredSync and syncScope values. If{' '}
+ filteredSync=Enabled, add an Entra security group of which the attacker-controlled user is
+ a direct member to the filter, as described for ManageEntraDSSyncFilter. If{' '}
+ filteredSync=Disabled but syncScope=CloudOnly, change syncScope{' '}
+ to All with the ARM PUT workflow described for AZManageEntraDS.
+
+
+ Wait for the controlled user to synchronize to Entra DS. Poll for its Entra object ID in{' '}
+ msDS-aadObjectId, or use repeated non-destructive authentication attempts when LDAP
+ inspection is unavailable.
+
+
+
+);
+
+const Opsec: FC = () => (
+
+ Managed-domain updates are recorded in the Azure Activity Log and can trigger a full resynchronization,
+ including deletion of objects that fall outside the new boundary.
+
+);
+
+const References: FC = () => (
+
+
+ Microsoft Entra Domain Services synchronization
+
+
+);
+
+const ManageEntraDSSync = {
+ general: General,
+ windowsAbuse: Abuse,
+ linuxAbuse: Abuse,
+ opsec: Opsec,
+ references: References,
+ composition: Composition,
+};
+
+export default ManageEntraDSSync;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
new file mode 100644
index 000000000000..797c473f9bad
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
@@ -0,0 +1,76 @@
+// Copyright 2026 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// SPDX-License-Identifier: Apache-2.0
+
+import { Box, Link } from '@mui/material';
+import { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const General: FC = () => (
+
+ ManageEntraDSSyncFilter means the Domain Controller Services service principal can assign groups to the filtered
+ synchronization scope of the correlated managed domain. BloodHound emits it only for application ID
+ 2565bd9d-da50-47d4-8b85-4c97f669dc36 when filteredSync is Enabled and syncScope is All.
+
+);
+
+const Abuse: FC = () => (
+
+
+
+ Select an attacker-controlled Entra security group containing an attacker-controlled
+ user as a direct member. Do not use a direct user app-role assignment or a nested group; Entra DS does
+ not honor either case for this synchronization path.
+
+
+ In the target tenant, find the service principal with{' '}
+ appId=2565bd9d-da50-47d4-8b85-4c97f669dc36. Read its appRoles and select the
+ enabled role whose display name is User.
+
+
+ Using the user or other directory context that controls the source service principal, create the group
+ entitlement with Microsoft Graph:
+
+ {
+ 'POST https://graph.microsoft.com/v1.0/servicePrincipals/{service-principal-object-id}/appRoleAssignedTo\nContent-Type: application/json\n\n{\n "principalId": "{attacker-group-object-id}",\n "resourceId": "{service-principal-object-id}",\n "appRoleId": "{user-app-role-id}"\n}'
+ }
+
+
+
+ Wait for Entra DS to add the group to CN=ScopedGroups,OU=AADDSSyncState,... and synchronize
+ the group and its direct user members. If the controlled user is cloud-only and lacks usable legacy
+ password material, change its password while Entra DS is active and wait for the password to
+ synchronize.
+
+
+
+);
+
+const Opsec: FC = () => (
+
+ App-role assignments and group membership changes generate Microsoft Entra audit activity. Subsequent use of the
+ synchronized identity can generate managed-domain authentication events.
+
+);
+
+const References: FC = () => (
+
+
+ Configure scoped synchronization
+
+
+);
+
+const ManageEntraDSSyncFilter = {
+ general: General,
+ windowsAbuse: Abuse,
+ linuxAbuse: Abuse,
+ opsec: Opsec,
+ references: References,
+};
+
+export default ManageEntraDSSyncFilter;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/General.tsx
deleted file mode 100644
index 9bb887eca896..000000000000
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/General.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright 2026 Specter Ops, Inc.
-//
-// Licensed under the Apache License, Version 2.0
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import { Typography } from 'doodle-ui';
-import { FC } from 'react';
-
-const General: FC = () => {
- return (
- <>
-
- This relationship indicates that control of either a Microsoft Entra Domain Services managed domain or,
- in the filtered-sync case described below, the Domain Controller Services service principal can be used
- to create usable Entra Domain Services users with baseline Domain Users access.
-
-
- BloodHound emits this relationship from an AZDomainService because the managed domain controls the broad
- synchronization boundary through its filtered sync and sync scope settings. A principal that controls
- the resource can change those settings so an attacker-controlled identity is eligible for
- synchronization.
-
-
- BloodHound can also emit this relationship from the Domain Controller Services service principal with
- application ID 2565bd9d-da50-47d4-8b85-4c97f669dc36, but only when the related managed domain has
- filtered sync set to Enabled and sync scope set to All. In that state, a principal that controls the
- service principal can assign an attacker-controlled Entra security group to the filtered synchronization
- scope. The direct members of that group are then materialized as Entra Domain Services users.
-
-
- Every newly materialized Entra Domain Services user receives Domain Users as its primary group. In
- BloodHound, Domain Users is already nested into Authenticated Users, which is in turn nested into
- Everyone, so this relationship represents baseline authenticated access to the managed domain.
-
-
- Direct user assignments may appear in the portal, but they are not honored by the Entra Domain Services
- sync engine. Only explicitly scoped groups and their direct members are synchronized.
-
- >
- );
-};
-
-export default General;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx
deleted file mode 100644
index 03e72584f367..000000000000
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/LinuxAbuse.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright 2026 Specter Ops, Inc.
-//
-// Licensed under the Apache License, Version 2.0
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import { Typography } from 'doodle-ui';
-import { FC } from 'react';
-
-const Abuse: FC = () => {
- return (
- <>
-
- From an AZDomainService source, change the managed domain synchronization settings so an
- attacker-controlled identity is eligible for synchronization, then wait for synchronization.
-
-
- From a Domain Controller Services service principal source, assign an attacker-controlled Entra security
- group to the filtered synchronization scope, add an attacker-controlled Entra user as a direct member of
- that group, and wait for synchronization. The user is then materialized in Entra Domain Services with
- Domain Users access. A cloud-only user may need an Entra password change before password material is
- available for authentication.
-
- >
- );
-};
-
-export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/References.tsx
deleted file mode 100644
index dc900ee74769..000000000000
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/References.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright 2026 Specter Ops, Inc.
-//
-// Licensed under the Apache License, Version 2.0
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import { Box, Link } from '@mui/material';
-import React, { FC } from 'react';
-
-const References: FC = () => {
- const references = [
- {
- label: 'Microsoft.AAD/domainServices reference',
- link: 'https://learn.microsoft.com/en-us/azure/templates/microsoft.aad/domainservices',
- },
- {
- label: 'Domain Services Contributor built-in role',
- link: 'https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/identity#domain-services-contributor',
- },
- {
- label: 'How objects and credentials are synchronized in a Microsoft Entra Domain Services managed domain',
- link: 'https://learn.microsoft.com/en-us/entra/identity/domain-services/synchronization',
- },
- {
- label: 'Configure scoped synchronization for Microsoft Entra Domain Services',
- link: 'https://learn.microsoft.com/en-us/entra/identity/domain-services/scoped-synchronization',
- },
- ];
-
- return (
-
- {references.map((reference) => {
- return (
-
-
- {reference.label}
-
-
-
- );
- })}
-
- );
-};
-
-export default References;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/SyncEntraDSUsers.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/SyncEntraDSUsers.tsx
deleted file mode 100644
index 0f3fa7371978..000000000000
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/SyncEntraDSUsers.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright 2026 Specter Ops, Inc.
-//
-// Licensed under the Apache License, Version 2.0
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import General from './General';
-import LinuxAbuse from './LinuxAbuse';
-import Opsec from './Opsec';
-import References from './References';
-import WindowsAbuse from './WindowsAbuse';
-
-const SyncEntraDSUsers = {
- general: General,
- windowsAbuse: WindowsAbuse,
- linuxAbuse: LinuxAbuse,
- opsec: Opsec,
- references: References,
-};
-
-export default SyncEntraDSUsers;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/WindowsAbuse.tsx
deleted file mode 100644
index 03e72584f367..000000000000
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncEntraDSUsers/WindowsAbuse.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright 2026 Specter Ops, Inc.
-//
-// Licensed under the Apache License, Version 2.0
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import { Typography } from 'doodle-ui';
-import { FC } from 'react';
-
-const Abuse: FC = () => {
- return (
- <>
-
- From an AZDomainService source, change the managed domain synchronization settings so an
- attacker-controlled identity is eligible for synchronization, then wait for synchronization.
-
-
- From a Domain Controller Services service principal source, assign an attacker-controlled Entra security
- group to the filtered synchronization scope, add an attacker-controlled Entra user as a direct member of
- that group, and wait for synchronization. The user is then materialized in Entra Domain Services with
- Domain Users access. A cloud-only user may need an Entra password change before password material is
- available for authentication.
-
- >
- );
-};
-
-export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/References.tsx
index 89492277f27f..113ce0e7c0d6 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/References.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/References.tsx
@@ -23,6 +23,10 @@ const References: FC = () => {
label: 'How objects and credentials are synchronized in a Microsoft Entra Domain Services managed domain',
link: 'https://learn.microsoft.com/en-us/entra/identity/domain-services/synchronization',
},
+ {
+ label: 'MITRE ATT&CK T1098.007: Account Manipulation - Additional Local or Domain Groups',
+ link: 'https://attack.mitre.org/techniques/T1098/007/',
+ },
];
return (
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx
index 4d06012f9d39..9e2c1fdfc0ac 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx
@@ -19,11 +19,30 @@ import { FC } from 'react';
const Abuse: FC = () => {
return (
-
- An attacker may authenticate as the Entra Domain Services user using the Entra user's credentials. For a
- cloud-only user that has not changed its password while the managed domain is active, changing the Entra
- user's password and waiting for Entra Domain Services synchronization to complete generates the password
- material required for authentication.
+
+ An attacker may authenticate as the Microsoft Entra Domain Services (Entra DS) user using the Entra user's
+ credentials:
+
+
+ Obtain the Entra user's current password, or use the control represented by the path to change or
+ reset it to a known value.
+
+
+ If the account is cloud-only and has not completed a qualifying password change while the managed
+ domain is active, perform that change. When a reset operation permits it, set{' '}
+ forceChangePasswordNextSignIn to false; otherwise complete the required
+ interactive password change before proceeding.
+
+
+ Wait for the legacy Kerberos and NTLM password material to synchronize. Do not treat the AD user's
+ existence alone as proof that its credential is usable; poll with a harmless authentication attempt
+ when pwdLastSet or equivalent synchronization evidence is unavailable.
+
+
+ Authenticate with the Entra UPN and the changed password through Kerberos, NTLM, LDAP, or a domain
+ logon.
+
+
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/References.tsx
index 89492277f27f..eb1e4717d7bd 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/References.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/References.tsx
@@ -23,6 +23,10 @@ const References: FC = () => {
label: 'How objects and credentials are synchronized in a Microsoft Entra Domain Services managed domain',
link: 'https://learn.microsoft.com/en-us/entra/identity/domain-services/synchronization',
},
+ {
+ label: 'MITRE ATT&CK T1078.002: Valid Accounts - Domain Accounts',
+ link: 'https://attack.mitre.org/techniques/T1078/002/',
+ },
];
return (
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx
index 4d06012f9d39..9e2c1fdfc0ac 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx
@@ -19,11 +19,30 @@ import { FC } from 'react';
const Abuse: FC = () => {
return (
-
- An attacker may authenticate as the Entra Domain Services user using the Entra user's credentials. For a
- cloud-only user that has not changed its password while the managed domain is active, changing the Entra
- user's password and waiting for Entra Domain Services synchronization to complete generates the password
- material required for authentication.
+
+ An attacker may authenticate as the Microsoft Entra Domain Services (Entra DS) user using the Entra user's
+ credentials:
+
+
+ Obtain the Entra user's current password, or use the control represented by the path to change or
+ reset it to a known value.
+
+
+ If the account is cloud-only and has not completed a qualifying password change while the managed
+ domain is active, perform that change. When a reset operation permits it, set{' '}
+ forceChangePasswordNextSignIn to false; otherwise complete the required
+ interactive password change before proceeding.
+
+
+ Wait for the legacy Kerberos and NTLM password material to synchronize. Do not treat the AD user's
+ existence alone as proof that its credential is usable; poll with a harmless authentication attempt
+ when pwdLastSet or equivalent synchronization evidence is unavailable.
+
+
+ Authenticate with the Entra UPN and the changed password through Kerberos, NTLM, LDAP, or a domain
+ logon.
+
+
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx
index 92eb30fb70fa..f4c84ed743e0 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/index.tsx
@@ -36,7 +36,7 @@ import AZAvereContributor from './AZAvereContributor/AZAvereContributor';
import AZCloudAppAdmin from './AZCloudAppAdmin/AZCloudAppAdmin';
import AZContains from './AZContains/AZContains';
import AZContributor from './AZContributor/AZContributor';
-import AZDomainServicesContributor from './AZDomainServicesContributor/AZDomainServicesContributor';
+import AZEntraDSContributor from './AZEntraDSContributor/AZEntraDSContributor';
import AZExecuteCommand from './AZExecuteCommand/AZExecuteCommand';
import AZGetCertificates from './AZGetCertificates/AZGetCertificates';
import AZGetKeys from './AZGetKeys/AZGetKeys';
@@ -57,6 +57,7 @@ import AZMGGroupMember_ReadWrite_All from './AZMGGroupMember_ReadWrite_All/AZMGG
import AZMGGroup_ReadWrite_All from './AZMGGroup_ReadWrite_All/AZMGGroup_ReadWrite_All';
import AZMGRoleManagement_ReadWrite_Directory from './AZMGRoleManagement_ReadWrite_Directory/AZMGRoleManagement_ReadWrite_Directory';
import AZMGServicePrincipalEndpoint_ReadWrite_All from './AZMGServicePrincipalEndpoint_ReadWrite_All/AZMGServicePrincipalEndpoint_ReadWrite_All';
+import AZManageEntraDS from './AZManageEntraDS/AZManageEntraDS';
import AZManagedIdentity from './AZManagedIdentity/AZManagedIdentity';
import AZMemberOf from './AZMemberOf/AZMemberOf';
import AZNodeResourceGroup from './AZNodeResourceGroup/AZNodeResourceGroup';
@@ -99,6 +100,7 @@ import DumpSMSAPassword from './DumpSMSAPassword/DumpSMSAPassword';
import Enroll from './Enroll/Enroll';
import EnrollOnBehalfOf from './EnrollOnBehalfOf/EnrollOnBehalfOf';
import EnterpriseCAFor from './EnterpriseCAFor/EnterpriseCAFor';
+import EntraDSFor from './EntraDSFor/EntraDSFor';
import ExecuteDCOM from './ExecuteDCOM/ExecuteDCOM';
import ExtendedByPolicy from './ExtendedByPolicy/ExtendedByPolicy';
import ForceChangePassword from './ForceChangePassword/ForceChangePassword';
@@ -115,6 +117,8 @@ import HostsCAService from './HostsCAService/HostsCAService';
import IssuedSignedBy from './IssuedSignedBy/IssuedSignedBy';
import ManageCA from './ManageCA/ManageCA';
import ManageCertificates from './ManageCertificates/ManageCertificates';
+import ManageEntraDSSync from './ManageEntraDSSync/ManageEntraDSSync';
+import ManageEntraDSSyncFilter from './ManageEntraDSSyncFilter/ManageEntraDSSyncFilter';
import MemberOf from './MemberOf/MemberOf';
import NTAuthStoreFor from './NTAuthStoreFor/NTAuthStoreFor';
import OIDGroupLink from './OIDGroupLink/OIDGroupLink';
@@ -129,7 +133,6 @@ import RootCAFor from './RootCAFor/RootCAFor';
import SQLAdmin from './SQLAdmin/SQLAdmin';
import SameForestTrust from './SameForestTrust/SameForestTrust';
import SpoofSIDHistory from './SpoofSIDHistory/SpoofSIDHistory';
-import SyncEntraDSUsers from './SyncEntraDSUsers/SyncEntraDSUsers';
import SyncLAPSPassword from './SyncLAPSPassword/SyncLAPSPassword';
import SyncedToADUser from './SyncedToADUser/SyncedToADUser';
import SyncedToEntraDSGroup from './SyncedToEntraDSGroup/SyncedToEntraDSGroup';
@@ -199,7 +202,8 @@ const EdgeInfoComponents = {
AZAvereContributor: AZAvereContributor,
AZContains: AZContains,
AZContributor: AZContributor,
- AZDomainServicesContributor: AZDomainServicesContributor,
+ AZEntraDSContributor: AZEntraDSContributor,
+ AZManageEntraDS: AZManageEntraDS,
AZExecuteCommand: AZExecuteCommand,
AZGetCertificates: AZGetCertificates,
AZGetKeys: AZGetKeys,
@@ -223,9 +227,11 @@ const EdgeInfoComponents = {
AddSelf: AddSelf,
AddKeyCredentialLink: AddKeyCredentialLink,
AddEntraDSGroupMember: AddEntraDSGroupMember,
+ EntraDSFor: EntraDSFor,
+ ManageEntraDSSync: ManageEntraDSSync,
+ ManageEntraDSSyncFilter: ManageEntraDSSyncFilter,
DCSync: DCSync,
SyncLAPSPassword: SyncLAPSPassword,
- SyncEntraDSUsers: SyncEntraDSUsers,
WriteAccountRestrictions: WriteAccountRestrictions,
WriteGPLink: WriteGPLink,
DumpSMSAPassword: DumpSMSAPassword,
diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoContent.test.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoContent.test.tsx
index 4215390ef472..8f03704c9c23 100644
--- a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoContent.test.tsx
+++ b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoContent.test.tsx
@@ -21,6 +21,7 @@ import { INHERITANCE_DROPDOWN_DESCRIPTION } from '../../../components/HelpTexts/
import {
ActiveDirectoryKindProperties,
ActiveDirectoryRelationshipKind,
+ AzureRelationshipKind,
CommonKindProperties,
} from '../../../graphSchema';
import { mockSourceKindsHandler } from '../../../mocks';
@@ -170,6 +171,16 @@ const selectedEdgeADCSESC4: RelationshipDetails = {
kind: { name: ActiveDirectoryRelationshipKind.ADCSESC4, relationship_kind_id: 4 },
};
+const selectedEdgeAZEntraDSContributor: RelationshipDetails = {
+ ...selectedEdge,
+ kind: { name: AzureRelationshipKind.EntraDSContributor, relationship_kind_id: 5 },
+};
+
+const selectedEdgeAZManageEntraDS: RelationshipDetails = {
+ ...selectedEdge,
+ kind: { name: AzureRelationshipKind.ManageEntraDS, relationship_kind_id: 6 },
+};
+
const selectedEdgeACLInheritance: RelationshipDetails = {
relationship_id: 2,
kind: { name: ActiveDirectoryRelationshipKind.GenericAll, relationship_kind_id: 2 },
@@ -220,6 +231,22 @@ describe('EdgeInfoContent', () => {
screen.queryByText('An unexpected error has occurred. Please refresh the page and try again.')
).not.toBeInTheDocument();
});
+ test('Selecting an AZEntraDSContributor edge shows its help sections', async () => {
+ render();
+
+ expect(await screen.findByText('General')).toBeInTheDocument();
+ expect(screen.getByText('Abuse')).toBeInTheDocument();
+ expect(screen.getByText('OPSEC')).toBeInTheDocument();
+ expect(screen.getByText('References')).toBeInTheDocument();
+ });
+ test('Selecting an AZManageEntraDS edge shows its help sections', async () => {
+ render();
+
+ expect(await screen.findByText('General')).toBeInTheDocument();
+ expect(screen.getByText('Abuse')).toBeInTheDocument();
+ expect(screen.getByText('OPSEC')).toBeInTheDocument();
+ expect(screen.getByText('References')).toBeInTheDocument();
+ });
test('Selecting an edge with a Computer target node that haslaps is enabled shows correct Windows Abuse text', async () => {
render();
From 50e72d03bce33f0306929b9d4fcafdd52663bb72 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Mon, 10 Aug 2026 19:54:27 +0200
Subject: [PATCH 14/18] Refine AZManageEntraDS composition
---
.../analysis/azure/azure_integration_test.go | 6 +-
.../analysis/azure/entra_domain_services.go | 86 +++++++++----------
.../HelpTexts/AZManageEntraDS/Composition.tsx | 5 +-
3 files changed, 48 insertions(+), 49 deletions(-)
diff --git a/packages/go/analysis/azure/azure_integration_test.go b/packages/go/analysis/azure/azure_integration_test.go
index 9a215368729d..733bf51157eb 100644
--- a/packages/go/analysis/azure/azure_integration_test.go
+++ b/packages/go/analysis/azure/azure_integration_test.go
@@ -1216,7 +1216,9 @@ func TestManageEntraDSRequiresARMAndBothDirectoryRoles(t *testing.T) {
graphAzure.TenantID: tenantID,
}), graphAzure.Entity, graphAzure.User)
- for _, principal := range []*graph.Node{appAdminRole, groupsAdminRole, armGroup, qualifiedUser, appOnlyUser, domainServicesContributor} {
+ // Role tenant scope is represented by each AZRole's tenantid property. AZManageEntraDS must not require or return
+ // tenant-to-role AZContains relationships.
+ for _, principal := range []*graph.Node{armGroup, qualifiedUser, appOnlyUser, domainServicesContributor} {
NewRelationship(t, &suite, tenant, principal, graphAzure.Contains)
}
NewRelationship(t, &suite, tenant, subscription, graphAzure.Contains)
@@ -1257,7 +1259,7 @@ func TestManageEntraDSRequiresARMAndBothDirectoryRoles(t *testing.T) {
composition, err := edgecomposition.GetEdgeCompositionPath(context.Background(), suite.GraphDB, edge)
require.NoError(t, err)
nodes := composition.AllNodes()
- assert.True(t, nodes.Contains(tenant))
+ assert.False(t, nodes.Contains(tenant))
assert.True(t, nodes.Contains(appAdminRole))
assert.True(t, nodes.Contains(groupsAdminRole))
assert.True(t, nodes.Contains(domainService))
diff --git a/packages/go/analysis/azure/entra_domain_services.go b/packages/go/analysis/azure/entra_domain_services.go
index c7f370f55cec..68dac0bb2e29 100644
--- a/packages/go/analysis/azure/entra_domain_services.go
+++ b/packages/go/analysis/azure/entra_domain_services.go
@@ -25,7 +25,6 @@ import (
"github.com/specterops/bloodhound/packages/go/bhlog/attr"
"github.com/specterops/bloodhound/packages/go/bhlog/measure"
azschema "github.com/specterops/bloodhound/packages/go/graphschema/azure"
- "github.com/specterops/bloodhound/packages/go/graphschema/common"
"github.com/specterops/dawgs/graph"
"github.com/specterops/dawgs/ops"
"github.com/specterops/dawgs/query"
@@ -48,21 +47,14 @@ func GetManageEntraDSEdgeComposition(ctx context.Context, db graph.Database, edg
return nil
}
- tenant, err := getEntraDSTenant(tx, domainService)
- if err != nil {
- return err
- } else if tenant == nil {
- return nil
- }
-
- applicationAdministratorPaths, err := getManageEntraDSRoleComposition(tx, tenant, source, azschema.ApplicationAdministratorRole)
+ applicationAdministratorPaths, err := getManageEntraDSRoleComposition(tx, domainService, source, azschema.ApplicationAdministratorRole)
if err != nil {
return err
} else if applicationAdministratorPaths.Len() == 0 {
return nil
}
- groupsAdministratorPaths, err := getManageEntraDSRoleComposition(tx, tenant, source, azschema.GroupsAdministratorRole)
+ groupsAdministratorPaths, err := getManageEntraDSRoleComposition(tx, domainService, source, azschema.GroupsAdministratorRole)
if err != nil {
return err
} else if groupsAdministratorPaths.Len() == 0 {
@@ -176,28 +168,46 @@ func getManageEntraDSARMComposition(tx graph.Transaction, source, domainService
return finalPaths, nil
}
-func getEntraDSTenant(tx graph.Transaction, domainService *graph.Node) (*graph.Node, error) {
- tenantID, err := domainService.Properties.Get(azschema.TenantID.String()).String()
+func getManageEntraDSRoles(tx graph.Transaction, tenantScopedNode *graph.Node, roleTemplateID string) (graph.NodeSet, error) {
+ roles, err := FetchDescendentKindByTenantID(tx, tenantScopedNode, azschema.Role)
if err != nil {
return nil, err
}
- tenants, err := ops.FetchNodes(tx.Nodes().Filter(query.Kind(query.Node(), azschema.Tenant)))
+ matchingRoles := graph.NewNodeSet()
+ for _, role := range roles {
+ if templateID, err := role.Properties.Get(azschema.RoleTemplateID.String()).String(); graph.IsErrPropertyNotFound(err) {
+ continue
+ } else if err != nil {
+ return nil, err
+ } else if strings.EqualFold(strings.TrimSpace(templateID), strings.TrimSpace(roleTemplateID)) {
+ matchingRoles.Add(role)
+ }
+ }
+
+ return matchingRoles, nil
+}
+
+func getManageEntraDSRolePrincipals(tx graph.Transaction, tenantScopedNode *graph.Node, roleTemplateID string) (graph.NodeSet, error) {
+ roles, err := getManageEntraDSRoles(tx, tenantScopedNode, roleTemplateID)
if err != nil {
return nil, err
}
- for _, tenant := range tenants {
- if objectID, err := tenant.Properties.Get(common.ObjectID.String()).String(); err == nil && strings.EqualFold(strings.TrimSpace(objectID), strings.TrimSpace(tenantID)) {
- return tenant, nil
- }
+
+ principals, err := roleMembers(tx, roles)
+ if err != nil {
+ return nil, err
+ }
+ for _, role := range roles {
+ principals.Remove(role.ID)
}
- return nil, nil
+ return principals, nil
}
-func getManageEntraDSRoleComposition(tx graph.Transaction, tenant, source *graph.Node, roleTemplateID string) (graph.PathSet, error) {
+func getManageEntraDSRoleComposition(tx graph.Transaction, tenantScopedNode, source *graph.Node, roleTemplateID string) (graph.PathSet, error) {
finalPaths := graph.NewPathSet()
- roles, err := TenantRoles(tx, tenant, roleTemplateID)
+ roles, err := getManageEntraDSRoles(tx, tenantScopedNode, roleTemplateID)
if err != nil {
return nil, err
}
@@ -223,19 +233,7 @@ func getManageEntraDSRoleComposition(tx graph.Transaction, tenant, source *graph
continue
}
- tenantPaths, err := ops.FetchPathSet(tx.Relationships().Filter(query.And(
- query.Equals(query.StartID(), tenant.ID),
- query.Equals(query.EndID(), role.ID),
- query.Kind(query.Relationship(), azschema.Contains),
- )))
- if err != nil {
- return nil, err
- } else if tenantPaths.Len() == 0 {
- continue
- }
-
finalPaths.AddPathSet(rolePaths)
- finalPaths.AddPathSet(tenantPaths)
}
return finalPaths, nil
@@ -244,6 +242,7 @@ func getManageEntraDSRoleComposition(tx graph.Transaction, tenant, source *graph
// ManageEntraDS creates the traversable AZManageEntraDS relationship only when the same
// effective principal has all permissions observed in validation: Contributor or Domain Services
// Contributor over the managed-domain resource, Application Administrator, and Groups Administrator.
+// Directory roles are scoped by their tenantid property; tenant-to-role AZContains is not required.
// The source ARM assignment may be direct, inherited through AZContains, or effective through nested
// AZMemberOf membership.
func ManageEntraDS(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) {
@@ -263,20 +262,17 @@ func ManageEntraDS(ctx context.Context, db graph.Database) (*post.AtomicPostProc
operation := post.NewPostRelationshipOperation(ctx, db, "AZManageEntraDS Post Processing")
for _, tenant := range tenants {
- roleAssignments, err := FetchTenantRoleAssignments(ctx, db, tenant)
- if err != nil {
- _ = operation.Done()
- return &operation.Stats, err
- }
-
- qualifiedPrincipals := roleAssignments.PrincipalsWithRole(azschema.ApplicationAdministratorRole)
- qualifiedPrincipals.And(roleAssignments.PrincipalsWithRole(azschema.GroupsAdministratorRole))
- if qualifiedPrincipals.Cardinality() == 0 {
- continue
- }
-
tenant := tenant
if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error {
+ applicationAdministrators, err := getManageEntraDSRolePrincipals(tx, tenant, azschema.ApplicationAdministratorRole)
+ if err != nil {
+ return err
+ }
+ groupsAdministrators, err := getManageEntraDSRolePrincipals(tx, tenant, azschema.GroupsAdministratorRole)
+ if err != nil {
+ return err
+ }
+
domainServices, err := FetchDescendentKindByTenantID(tx, tenant, azschema.EntraDS)
if err != nil {
return err
@@ -289,7 +285,7 @@ func ManageEntraDS(ctx context.Context, db graph.Database) (*post.AtomicPostProc
}
for _, controller := range controllers {
- if qualifiedPrincipals.Contains(controller.ID.Uint64()) {
+ if applicationAdministrators.ContainsID(controller.ID) && groupsAdministrators.ContainsID(controller.ID) {
if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{
FromID: controller.ID,
ToID: domainService.ID,
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
index 336addc4153c..fb5ad38b4480 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
@@ -33,8 +33,9 @@ const Composition: FC = ({ sourceDBId, targetDBId, edgeName }) =>
<>
The relationship combines the source principal's effective Contributor or Domain Services Contributor
- path to this managed domain with its Application Administrator and Groups Administrator role paths in
- the tenant. All three permission components must apply to the same source principal.
+ path to this managed domain with its Application Administrator and Groups Administrator role paths. All
+ three permission components must apply to the same source principal. Tenant-to-role containment is
+ neither required nor included in this composition.
{isLoading ? (
From ca4ddffe7c6cd4c352175a7b3b518a22e31be636 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Tue, 11 Aug 2026 04:38:39 +0200
Subject: [PATCH 15/18] fix: tolerate invalid Entra DS sync prerequisites
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.
---
packages/go/analysis/hybrid/hybrid.go | 16 +++++-
.../hybrid/hybrid_integration_test.go | 51 +++++++++++++++++++
2 files changed, 66 insertions(+), 1 deletion(-)
diff --git a/packages/go/analysis/hybrid/hybrid.go b/packages/go/analysis/hybrid/hybrid.go
index 98fe95636133..3fd73620f38f 100644
--- a/packages/go/analysis/hybrid/hybrid.go
+++ b/packages/go/analysis/hybrid/hybrid.go
@@ -503,6 +503,14 @@ func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, ent
}
for _, runsAsRelationship := range runsAsRelationships {
+ // Merged Azure application and service-principal records can produce a
+ // self-referential AZRunsAs edge. It cannot identify two distinct sides
+ // of the scoped-sync application relationship and FetchRelationshipNodes
+ // intentionally requires two nodes, so ignore it as non-evidence.
+ if runsAsRelationship.StartID == runsAsRelationship.EndID {
+ continue
+ }
+
application, servicePrincipal, err := ops.FetchRelationshipNodes(tx, runsAsRelationship)
if err != nil {
return err
@@ -547,7 +555,13 @@ func filterContainedDomainUsers(tx graph.Transaction, domain *graph.Node, domain
return isDomainUserGroup
},
})
- if err != nil {
+ // PostgreSQL reports an empty traversal as ErrNoResultsFound while other
+ // graph drivers return an empty PathSet. Missing containment means that the
+ // broad synchronization edge is not supported; it must not fail the entire
+ // hybrid post-processing operation or suppress unrelated derived edges.
+ if errors.Is(err, graph.ErrNoResultsFound) {
+ return nil, nil
+ } else if err != nil {
return nil, err
}
diff --git a/packages/go/analysis/hybrid/hybrid_integration_test.go b/packages/go/analysis/hybrid/hybrid_integration_test.go
index 3e2ee25be575..63caa115716b 100644
--- a/packages/go/analysis/hybrid/hybrid_integration_test.go
+++ b/packages/go/analysis/hybrid/hybrid_integration_test.go
@@ -620,6 +620,57 @@ func TestManageEntraDSSyncEdges(t *testing.T) {
},
)
})
+
+ t.Run("SelfReferentialRunsAsIsIgnored", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var syncHarness manageEntraDSSyncHarness
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ syncHarness = setupManageEntraDSSyncHarness(t, testContext, validManageEntraDSSyncOptions())
+ mergedApplication := testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.ObjectID: integration.RandomObjectID(t),
+ azure.TenantID: integration.RandomObjectID(t),
+ }), azure.Entity, azure.App, azure.ServicePrincipal)
+ testContext.NewRelationship(mergedApplication, mergedApplication, azure.RunsAs)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ _, err := PostHybrid(context.Background(), db)
+ require.NoError(t, err)
+ verifyManageEntraDSSyncEdges(t, db, syncHarness, true, true, true)
+ },
+ )
+ })
+}
+
+func TestFilterContainedDomainUsersEmptyTraversal(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var domain, domainUsers *graph.Node
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ domain = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "SPECTER.DEV",
+ common.ObjectID: integration.RandomDomainSID(),
+ }), ad.Entity, ad.Domain)
+ domainUsers = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "DOMAIN USERS@SPECTER.DEV",
+ common.ObjectID: integration.RandomDomainSID(),
+ }), ad.Entity, ad.Group)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ var containedDomainUsers []graph.ID
+ err := db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
+ var err error
+ containedDomainUsers, err = filterContainedDomainUsers(tx, domain, []graph.ID{domainUsers.ID})
+ return err
+ })
+ require.NoError(t, err)
+ assert.Empty(t, containedDomainUsers)
+ },
+ )
}
func TestGetManageEntraDSSyncEdgeComposition(t *testing.T) {
From 9b02b6d51b4f0f5f8068c3267f06efa078b5b372 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Tue, 11 Aug 2026 17:43:22 +0200
Subject: [PATCH 16/18] fix: harden Entra DS hybrid analysis
---
packages/go/analysis/hybrid/hybrid.go | 39 ++++++++++++-------
.../hybrid/hybrid_integration_test.go | 30 +++++++++++++-
2 files changed, 54 insertions(+), 15 deletions(-)
diff --git a/packages/go/analysis/hybrid/hybrid.go b/packages/go/analysis/hybrid/hybrid.go
index 3fd73620f38f..e46a597f06b7 100644
--- a/packages/go/analysis/hybrid/hybrid.go
+++ b/packages/go/analysis/hybrid/hybrid.go
@@ -173,14 +173,21 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
// Now that we know which AZ users and AZ groups are synced to Entra Domain Services, compute the
// AddEntraDSGroupMember edges (an Entra DS-synced AZUser that can add or remove members from an Entra DS-synced AZGroup)
if err := addAddEntraDSGroupMemberEdges(tx, syncedToEntraDSUserEdgeMap, syncedToEntraDSGroupEdgeMap, addEntraDSGroupMemberEdgeMap); err != nil {
- return err
+ return fmt.Errorf("adding Entra DS group membership relationships: %w", err)
}
// A qualified AZManageEntraDS principal controls the broad synchronization boundary. The Domain Controller
// Services service principal can only add users through filtered group scope when the related managed domain is
// currently configured for filtered synchronization across all users.
if err := addManageEntraDSSyncEdges(tx, adGroups, entraDSAdminGroupTenantMap, syncedToEntraDSGroupEdgeMap, entraDSForEdgeMap, manageEntraDSSyncEdgeMap, manageEntraDSSyncFilterEdgeMap); err != nil {
- return err
+ if !errors.Is(err, graph.ErrNoResultsFound) {
+ return fmt.Errorf("adding Entra DS synchronization relationships: %w", err)
+ }
+
+ // The synchronization-control relationships depend on optional evidence from both graph platforms. Missing
+ // evidence must fail closed for those relationships without suppressing the independently supported identity
+ // correlation and group-control relationships assembled above.
+ slog.WarnContext(ctx, "Skipping incomplete Entra DS synchronization correlation", attr.Error(err))
}
if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error {
@@ -293,13 +300,19 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
return err
}
- return tx.Commit()
+ return nil
})
- // Because we need to close the operation either way at this stage, we attempt to close it and then report either or
- // both errors in one line
- if opErr := operation.Done(); opErr != nil || err != nil {
- return &operation.Stats, fmt.Errorf("marking operation as done: %w; transaction error (if any): %v", opErr, err)
+ // Close the operation even when the read phase failed so in-flight workers cannot leak. Keep the read and write
+ // failures distinct; formatting a nil operation error with %w obscures the actual cause.
+ if opErr := operation.Done(); opErr != nil {
+ if err != nil {
+ return &operation.Stats, fmt.Errorf("marking hybrid operation as done: %w; read error: %v", opErr, err)
+ }
+
+ return &operation.Stats, fmt.Errorf("marking hybrid operation as done: %w", opErr)
+ } else if err != nil {
+ return &operation.Stats, fmt.Errorf("reading hybrid relationship inputs: %w", err)
}
return &operation.Stats, nil
@@ -400,7 +413,7 @@ func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, ent
domains, err := fetchADDomains(tx)
if err != nil {
- return err
+ return fmt.Errorf("fetching AD domains: %w", err)
}
for _, domain := range domains {
@@ -414,7 +427,7 @@ func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, ent
domainServices, err := fetchEntraDomainServices(tx)
if err != nil {
- return err
+ return fmt.Errorf("fetching Entra DS resources: %w", err)
}
for _, domainService := range domainServices {
@@ -458,7 +471,7 @@ func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, ent
containedDomainUserGroups, err := filterContainedDomainUsers(tx, domain, domainUserGroups)
if err != nil {
- return err
+ return fmt.Errorf("finding Domain Users containment for domain %d: %w", domain.ID, err)
}
if len(containedDomainUserGroups) > 0 {
@@ -470,7 +483,7 @@ func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, ent
)
}))
if err != nil {
- return err
+ return fmt.Errorf("fetching Entra DS managers for resource %d: %w", domainService.ID, err)
}
for _, manager := range managers {
@@ -499,7 +512,7 @@ func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, ent
)
}))
if err != nil {
- return err
+ return fmt.Errorf("fetching Domain Controller Services application relationships: %w", err)
}
for _, runsAsRelationship := range runsAsRelationships {
@@ -513,7 +526,7 @@ func addManageEntraDSSyncEdges(tx graph.Transaction, adGroups []*graph.Node, ent
application, servicePrincipal, err := ops.FetchRelationshipNodes(tx, runsAsRelationship)
if err != nil {
- return err
+ return fmt.Errorf("fetching endpoints for AZRunsAs relationship %d: %w", runsAsRelationship.ID, err)
}
applicationID, hasApplicationID, err := normalizedNodeProperty(application, common.ObjectID.String())
diff --git a/packages/go/analysis/hybrid/hybrid_integration_test.go b/packages/go/analysis/hybrid/hybrid_integration_test.go
index 63caa115716b..b40e134824e5 100644
--- a/packages/go/analysis/hybrid/hybrid_integration_test.go
+++ b/packages/go/analysis/hybrid/hybrid_integration_test.go
@@ -578,6 +578,24 @@ func TestManageEntraDSSyncEdges(t *testing.T) {
}(),
expectCorrelation: true, expectManageSync: false, expectManageFilter: true,
},
+ {
+ name: "MissingManagerDoesNotSuppressCorrelationOrFilterEdge",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.manageDomainService = false
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: false, expectManageFilter: true,
+ },
+ {
+ name: "MissingRunsAsDoesNotSuppressCorrelationOrManagerEdge",
+ options: func() manageEntraDSSyncHarnessOptions {
+ options := validManageEntraDSSyncOptions()
+ options.includeRunsAs = false
+ return options
+ }(),
+ expectCorrelation: true, expectManageSync: true, expectManageFilter: false,
+ },
}
for _, testCase := range testCases {
@@ -738,6 +756,8 @@ func TestGetManageEntraDSSyncEdgeComposition(t *testing.T) {
type manageEntraDSSyncHarnessOptions struct {
applicationID string
adminGroupName string
+ manageDomainService bool
+ includeRunsAs bool
sameTenant bool
syncAdminGroup bool
matchingDomainName bool
@@ -756,6 +776,8 @@ func validManageEntraDSSyncOptions() manageEntraDSSyncHarnessOptions {
return manageEntraDSSyncHarnessOptions{
applicationID: entraDSScopedSyncApplicationID,
adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
+ manageDomainService: true,
+ includeRunsAs: true,
sameTenant: true,
syncAdminGroup: true,
matchingDomainName: true,
@@ -812,11 +834,15 @@ func setupManageEntraDSSyncHarness(t *testing.T, testContext *integration.GraphT
manager := testContext.NewAzureGroup("Managed Domain Manager", integration.RandomObjectID(t), tenantID)
azAdminGroupObjectID := integration.RandomObjectID(t)
azAdminGroup := testContext.NewAzureGroup(options.adminGroupName, azAdminGroupObjectID, tenantID)
- testContext.NewRelationship(application, servicePrincipal, azure.RunsAs)
+ if options.includeRunsAs {
+ testContext.NewRelationship(application, servicePrincipal, azure.RunsAs)
+ }
testContext.NewRelationship(servicePrincipalTenant, servicePrincipal, azure.Contains)
testContext.NewRelationship(tenant, manager, azure.Contains)
testContext.NewRelationship(tenant, azAdminGroup, azure.Contains)
- testContext.NewRelationship(manager, domainService, azure.ManageEntraDS)
+ if options.manageDomainService {
+ testContext.NewRelationship(manager, domainService, azure.ManageEntraDS)
+ }
adminGroupAADObjectID := integration.RandomObjectID(t)
if options.syncAdminGroup {
From b4614a5a58fa76488cc92662366f053fa0337856 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Tue, 11 Aug 2026 17:43:37 +0200
Subject: [PATCH 17/18] docs: clarify Entra DS identity correlation
---
.../AddEntraDSGroupMember/General.tsx | 27 ++++++++++++-------
.../HelpTexts/SyncedToEntraDSUser/General.tsx | 19 ++++++-------
2 files changed, 27 insertions(+), 19 deletions(-)
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
index ac3b63296ea3..daa5e55d7ba2 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
@@ -21,20 +21,27 @@ const General: FC = () => {
return (
<>
- This relationship indicates that a synchronized Entra user can effectively add or remove members from an
- Entra Domain Services group by controlling the corresponding synchronized Entra group.
+ This relationship indicates that a synchronized Entra user can effectively add or remove members from a
+ Microsoft Entra Domain Services (Entra DS) group by controlling the corresponding synchronized Entra
+ group.
- The relationship is composed from three conditions: the Entra user is synchronized to Entra Domain
- Services; the Entra user owns or can add and remove members from an Entra group; and the Entra group is
- synchronized to an Entra Domain Services group.
+ The relationship is composed from three conditions: BloodHound correlates the Entra user with an Entra
+ DS user; the Entra user owns or can add and remove members from an Entra group; and the Entra group is
+ synchronized to an Entra DS group.
- Because the Entra user already has a usable Entra Domain Services identity, they can add themselves or
- another controlled synchronized principal to the Entra group, remove existing members, and wait for the
- membership change to synchronize into the Entra Domain Services group. Adding membership effectively
- grants the Entra user any privileges held by the Entra Domain Services group; removing membership can
- revoke those privileges from another principal.
+ The user can add themselves or another controlled synchronized principal to the Entra group, remove
+ existing members, and wait for the membership change to synchronize into the Entra DS group. Adding
+ membership can grant privileges held by the Entra DS group; removing membership can revoke those
+ privileges from another principal.
+
+
+ User correlation relies on the BloodHound aadobjectid property. Current collection does not include the
+ Entra user's identities, creationType, or externalUserState properties, so B2B external identities
+ can be misclassified. BloodHound also does not verify synchronized password material or runtime
+ credential usability, which can make this composed relationship a false positive for direct
+ exploitation.
Only direct membership in the source Entra group is synchronized. Nested Entra groups do not satisfy
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
index faa4cc6f97c1..6d5241e2c61d 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
@@ -21,19 +21,20 @@ const General: FC = () => {
return (
<>
- This relationship indicates that the Entra user and the Entra Domain Services user are the same identity
- across the Entra ID and managed domain boundary.
+ This relationship indicates that BloodHound correlated a Microsoft Entra user with a user in a Microsoft
+ Entra Domain Services (Entra DS) managed domain.
- The Entra Domain Services user is created from the Entra user during synchronization and can be
- correlated through the BloodHound aadobjectid property, collected from the LDAP attribute
- msDS-aadObjectId. Password changes in Entra ID generate and synchronize the password material required
- for the Entra Domain Services user to authenticate.
+ Correlation uses the BloodHound aadobjectid property, collected from the LDAP attribute
+ msDS-aadObjectId. Current collection does not include the Entra user's identities, creationType, or
+ externalUserState properties, so B2B external identities can be misclassified. Treat this edge as
+ evidence of correlation, not proof that the source user can authenticate to the managed domain.
- For cloud-only users, Entra ID does not generate the NT hash required by Entra Domain Services until a
- password change occurs while the managed domain is active. A newly synchronized cloud-only user may
- exist in Entra Domain Services but remain unusable until the password is changed in Entra ID.
+ For cloud-only users, Entra ID does not generate the NT hash required by Entra DS until a password
+ change occurs while the managed domain is active. A newly synchronized cloud-only user may exist in
+ Entra DS but remain unusable until the password is changed in Entra ID. BloodHound does not verify
+ password-material availability or runtime credential usability.
>
);
From dfd59d05661f8282419822a658599f0bd4d96128 Mon Sep 17 00:00:00 2001
From: Martin Sohn Christensen
Date: Tue, 11 Aug 2026 22:36:24 +0200
Subject: [PATCH 18/18] docs: complete Entra DS help and queries
---
.../bh-shared-ui/src/commonSearchesAGI.ts | 6 +++
.../bh-shared-ui/src/commonSearchesAGT.ts | 6 +++
.../HelpTexts/AZContributor/Abuse.tsx | 8 ++--
.../AZEntraDSContributor/General.tsx | 17 +++++---
.../AZManageEntraDS/AZManageEntraDS.tsx | 17 +++++---
.../AddEntraDSGroupMember/General.tsx | 7 ----
.../HelpTexts/EntraDSFor/EntraDSFor.tsx | 39 ++++++++++++++++---
.../ManageEntraDSSync/ManageEntraDSSync.tsx | 28 ++++++++++---
.../ManageEntraDSSyncFilter.tsx | 30 +++++++++++---
.../SyncedToEntraDSGroup/General.tsx | 20 +++++-----
.../HelpTexts/SyncedToEntraDSUser/General.tsx | 13 +++++--
11 files changed, 138 insertions(+), 53 deletions(-)
diff --git a/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts b/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
index 5ad1dfb1f355..260d2ff738df 100644
--- a/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
+++ b/packages/javascript/bh-shared-ui/src/commonSearchesAGI.ts
@@ -481,6 +481,12 @@ RETURN p\nLIMIT 1000`,
description: '',
query: `OPTIONAL MATCH broadSyncPath = (:AZBase)-[:ManageEntraDSSync]->(:Group)\n\nOPTIONAL MATCH filteredSyncPath = (app:AZApp)-[:AZRunsAs]->(sp:AZServicePrincipal)-[:ManageEntraDSSyncFilter]->(:Group)\nWHERE app.objectid = '2565BD9D-DA50-47D4-8B85-4C97F669DC36'\n\nOPTIONAL MATCH applicationControlPath = (app)<-[:AZ_ATTACK_PATHS]-(applicationController:AZBase)\nWHERE NOT (applicationController)-[:SyncedToEntraDSUser]->(:User)\n\nOPTIONAL MATCH servicePrincipalControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(servicePrincipalController:AZBase)\nWHERE NOT (servicePrincipalController)-[:SyncedToEntraDSUser]->(:User)\nAND (servicePrincipalController:AZUser OR servicePrincipalController:AZServicePrincipal)\n\nOPTIONAL MATCH roleControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(:AZRole)-[:AZRoleEligible|AZHasRole]-(roleAssignee:AZBase)\nWHERE NOT (roleAssignee)-[:SyncedToEntraDSUser]->(:User)\n\nRETURN broadSyncPath,filteredSyncPath,applicationControlPath,servicePrincipalControlPath,roleControlPath\nLIMIT 1000`,
},
+ {
+ name: 'Principals that can manage Entra DS',
+ 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`,
+ },
{
name: 'Members of the Entra DS Administrators group',
description: '',
diff --git a/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts b/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
index cf942942da74..3ea225cc3649 100644
--- a/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
+++ b/packages/javascript/bh-shared-ui/src/commonSearchesAGT.ts
@@ -481,6 +481,12 @@ RETURN p\nLIMIT 1000`,
description: '',
query: `OPTIONAL MATCH broadSyncPath = (:AZBase)-[:ManageEntraDSSync]->(:Group)\n\nOPTIONAL MATCH filteredSyncPath = (app:AZApp)-[:AZRunsAs]->(sp:AZServicePrincipal)-[:ManageEntraDSSyncFilter]->(:Group)\nWHERE app.objectid = '2565BD9D-DA50-47D4-8B85-4C97F669DC36'\n\nOPTIONAL MATCH applicationControlPath = (app)<-[:AZ_ATTACK_PATHS]-(applicationController:AZBase)\nWHERE NOT (applicationController)-[:SyncedToEntraDSUser]->(:User)\n\nOPTIONAL MATCH servicePrincipalControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(servicePrincipalController:AZBase)\nWHERE NOT (servicePrincipalController)-[:SyncedToEntraDSUser]->(:User)\nAND (servicePrincipalController:AZUser OR servicePrincipalController:AZServicePrincipal)\n\nOPTIONAL MATCH roleControlPath = (sp)<-[:AZ_ATTACK_PATHS]-(:AZRole)-[:AZRoleEligible|AZHasRole]-(roleAssignee:AZBase)\nWHERE NOT (roleAssignee)-[:SyncedToEntraDSUser]->(:User)\n\nRETURN broadSyncPath,filteredSyncPath,applicationControlPath,servicePrincipalControlPath,roleControlPath\nLIMIT 1000`,
},
+ {
+ name: 'Principals that can manage Entra DS',
+ 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`,
+ },
{
name: 'Members of the Entra DS Administrators group',
description: '',
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx
index 88e7b9bb889e..d3c6ac0a58d4 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/Abuse.tsx
@@ -37,10 +37,10 @@ const Abuse: FC = () => {
Microsoft Entra Domain Services: Contributor supplies the Azure Resource Manager
- portion of managed-domain configuration authorization. Live validation required the same effective
- principal to also have Application Administrator and Groups Administrator before changing a
- representative security setting, syncScope, or filteredSync. BloodHound represents that conjunction with
- the post-processed AZManageEntraDS edge.
+ portion of managed-domain configuration authorization. The same effective principal must also have
+ Application Administrator and Groups Administrator to change managed-domain security settings,
+ syncScope, or filteredSync. BloodHound represents that conjunction with the post-processed
+ AZManageEntraDS edge.
Resource Group: NOT abusable, and not collected by AzureHound
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
index 9210aa2d876d..c138f25d54c1 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
@@ -19,11 +19,18 @@ import { FC } from 'react';
const General: FC = () => {
return (
-
- AZEntraDSContributor records an assignment of the built-in Domain Services Contributor Azure Resource
- Manager role on the target AZEntraDS resource. It is raw authorization evidence and is not independently
- traversable.
-
+ <>
+
+ AZEntraDSContributor records raw Azure Resource Manager authorization. The built-in Domain Services
+ Contributor role, definition ID eeaeda52-9324-47f6-8069-5d5bade478b2, grants{' '}
+ Microsoft.AAD/domainServices/* and related network permissions over the target AZEntraDS
+ resource.
+
+
+ The edge is not independently traversable. BloodHound creates AZManageEntraDS only when the same
+ effective principal also has Application Administrator and Groups Administrator.
+
+ >
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
index 8b96da54f70b..10c6ba4b0b68 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
@@ -21,18 +21,23 @@ import References from '../AZEntraDSContributor/References';
import Composition from './Composition';
const General: FC = () => (
-
- AZManageEntraDS is a post-processed, traversable relationship. The same effective principal has Contributor or
- raw AZEntraDSContributor over the managed domain and also has Application Administrator and Groups Administrator
- in the tenant.
+
+ BloodHound creates this post-processed relationship only when one effective principal has:
+
+
AZContributor or raw AZEntraDSContributor over the target AZEntraDS resource.
+
Application Administrator in the target tenant.
+
Groups Administrator in the target tenant.
+
+ Post-processing accounts for inherited ARM scope, nested Azure group membership, and effective Entra role
+ assignments. Role scope is matched from AZRole.tenantid; a tenant-to-role AZContains relationship
+ is not required.
);
const Abuse: FC = () => (
The source can change the Microsoft Entra Domain Services (Entra DS) managed domain's security configuration and
- broad synchronization boundary. Live validation confirmed changes to a representative security setting,{' '}
- syncScope, and filteredSync only when all three authorization components were present.
+ broad synchronization boundary when all three authorization components are present.
Obtain an Azure Resource Manager access token and all three authorization components represented by the
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
index daa5e55d7ba2..391bc6736c02 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.tsx
@@ -36,13 +36,6 @@ const General: FC = () => {
membership can grant privileges held by the Entra DS group; removing membership can revoke those
privileges from another principal.
-
- User correlation relies on the BloodHound aadobjectid property. Current collection does not include the
- Entra user's identities, creationType, or externalUserState properties, so B2B external identities
- can be misclassified. BloodHound also does not verify synchronized password material or runtime
- credential usability, which can make this composed relationship a false positive for direct
- exploitation.
-
Only direct membership in the source Entra group is synchronized. Nested Entra groups do not satisfy
this relationship.
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
index 7ed3d688db51..618a58d4e1c2 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
@@ -3,17 +3,44 @@
// Licensed under the Apache License, Version 2.0
// SPDX-License-Identifier: Apache-2.0
+import { Box, Link } from '@mui/material';
import { Typography } from 'doodle-ui';
import { FC } from 'react';
const General: FC = () => (
-
- EntraDSFor is a non-traversable, post-processed correlation from an AZEntraDS resource to its managed AD Domain.
- BloodHound requires matching normalized domain names and corroborates the domain SID through the tenant's
- synchronized AAD DC Administrators group.
-
+ <>
+
+ EntraDSFor is a non-traversable, post-processed correlation from a Microsoft Entra Domain Services (Entra
+ DS) resource to its managed AD Domain. BloodHound requires a unique normalized match between{' '}
+ AZEntraDS.domainname and Domain.name, then corroborates the domain SID through the
+ tenant's synchronized AAD DC Administrators group.
+
+
+ The group must be represented by an AZGroup named AAD DC Administrators, a SyncedToEntraDSGroup relationship
+ to its Entra DS Group, and a matching domainsid on the candidate Domain. BloodHound fails
+ closed when the name match is missing or ambiguous or the group correlation does not corroborate the SID.
+
+ >
);
-const EntraDSFor = { general: General };
+const References: FC = () => (
+
+
+ How objects and credentials are synchronized in a Microsoft Entra Domain Services managed domain
+
+
+
+ Configure the AAD DC Administrators group
+
+
+);
+
+const EntraDSFor = { general: General, references: References };
export default EntraDSFor;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
index f6e7c0dce37d..78c80324215f 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
@@ -9,11 +9,18 @@ import { FC } from 'react';
import Composition from './Composition';
const General: FC = () => (
-
- ManageEntraDSSync means the source can broaden the synchronization boundary of the correlated Microsoft Entra
- Domain Services (Entra DS) domain and cause eligible Entra users to materialize with Domain Users access. It is
- emitted from an AZManageEntraDS principal regardless of the current filteredSync and syncScope settings.
-
+ <>
+
+ BloodHound emits ManageEntraDSSync from each principal with AZManageEntraDS to a correlated Microsoft Entra
+ Domain Services (Entra DS) resource. The destination is the RID 513 Domain Users group in the Domain
+ identified by EntraDSFor.
+
+
+ The relationship is independent of current filteredSync and syncScope values
+ because the source can change both settings. BloodHound does not emit an unconditional attack edge from the
+ AZEntraDS resource.
+
+ >
);
const Abuse: FC = () => (
@@ -50,6 +57,17 @@ const References: FC = () => (
href='https://learn.microsoft.com/en-us/entra/identity/domain-services/synchronization'>
Microsoft Entra Domain Services synchronization
+
+
+ Configure scoped synchronization
+
+
+
+ MITRE ATT&CK T1136.002: Create Account - Domain Account
+
);
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
index 797c473f9bad..9ebcf880a933 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
@@ -8,11 +8,17 @@ import { Typography } from 'doodle-ui';
import { FC } from 'react';
const General: FC = () => (
-
- ManageEntraDSSyncFilter means the Domain Controller Services service principal can assign groups to the filtered
- synchronization scope of the correlated managed domain. BloodHound emits it only for application ID
- 2565bd9d-da50-47d4-8b85-4c97f669dc36 when filteredSync is Enabled and syncScope is All.
-
+ <>
+
+ BloodHound emits ManageEntraDSSyncFilter from the service principal associated through AZRunsAs with Domain
+ Controller Services application ID 2565bd9d-da50-47d4-8b85-4c97f669dc36. The service principal
+ and Microsoft Entra Domain Services (Entra DS) resource must belong to the same tenant, and the resource
+ must have filteredSync=Enabled and syncScope=All.
+
+
+ The destination is the RID 513 Domain Users group in the Domain identified by EntraDSFor.
+
+ >
);
const Abuse: FC = () => (
@@ -62,6 +68,20 @@ const References: FC = () => (
href='https://learn.microsoft.com/en-us/entra/identity/domain-services/scoped-synchronization'>
Configure scoped synchronization
+
+
+ Microsoft Entra Domain Services synchronization
+
+
+
+ Microsoft Graph appRoleAssignedTo resource
+
);
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
index aeefedf14be7..4cfc81870c74 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
@@ -21,24 +21,22 @@ const General: FC = () => {
return (
<>
- This relationship indicates that the Entra group and the Entra Domain Services group are the same group
- across the Entra ID and managed domain boundary.
+ This relationship indicates that the Entra group and the Microsoft Entra Domain Services (Entra DS)
+ group are the same group across the Entra ID and managed domain boundary.
- The Entra Domain Services group is created from the Entra group during synchronization and can be
- correlated through the BloodHound aadobjectid property, collected from the LDAP attribute
- msDS-aadObjectId. Membership changes made to the Entra group are synchronized into the corresponding
- Entra Domain Services group.
+ The Entra DS group is created from the Entra group during synchronization and can be correlated through
+ the BloodHound aadobjectid property, collected from the LDAP attribute msDS-aadObjectId. Membership
+ changes made to the Entra group are synchronized into the corresponding Entra DS group.
- Only direct membership is synchronized. Nested Entra groups do not become nested Entra Domain Services
- groups through this relationship.
+ Only direct membership is synchronized. Nested Entra groups do not become nested Entra DS groups through
+ this relationship.
This relationship is informational. Control of the Entra group does not by itself provide a usable Entra
- Domain Services identity. The related AddEntraDSGroupMember edge captures the case where a synchronized
- Entra user can use control of a synchronized Entra group to gain effective membership in the Entra
- Domain Services group.
+ DS identity. The related AddEntraDSGroupMember edge captures the case where a synchronized Entra user
+ can use control of a synchronized Entra group to gain effective membership in the Entra DS group.
>
);
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
index 6d5241e2c61d..ceb39e26d341 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
@@ -25,10 +25,15 @@ const General: FC = () => {
Entra Domain Services (Entra DS) managed domain.
- Correlation uses the BloodHound aadobjectid property, collected from the LDAP attribute
- msDS-aadObjectId. Current collection does not include the Entra user's identities, creationType, or
- externalUserState properties, so B2B external identities can be misclassified. Treat this edge as
- evidence of correlation, not proof that the source user can authenticate to the managed domain.
+ The Entra DS user is created from the Entra user during synchronization and can be correlated through
+ the BloodHound aadobjectid property, collected from the LDAP attribute{' '}
+ msDS-aadObjectId. Password changes in Entra ID generate and synchronize the password
+ material required for the Entra DS user to authenticate.
+
+
+ BloodHound does not consider whether the Entra user is a B2B external identity when creating this
+ relationship. A B2B external identity will be synchronized to Entra DS but cannot authenticate to the
+ managed domain by design.
For cloud-only users, Entra ID does not generate the NT hash required by Entra DS until a password