diff --git a/.gitignore b/.gitignore
index cb1e9eafd415..8ecb6d243dda 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@
# Codex
.codex
+# Claude
+.claude/settings.local.json
+
# Augment
.augment
@@ -263,6 +266,9 @@ fabric.properties
# Test binary, built with `go test -c`
*.test
+# Repository-local Go build cache overrides
+.gocache/
+
# Golang workspace sum files
go.work.sum
diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go
index 21b48164113f..bb7eda38c1e9 100644
--- a/cmd/api/src/api/v2/azure.go
+++ b/cmd/api/src/api/v2/azure.go
@@ -68,6 +68,7 @@ const (
entityTypeServicePrincipals = "service-principals"
entityTypeRoles = "roles"
entityTypeFunctionApps = "function-apps"
+ entityTypeDomainServices = "domain-services"
entityTypeFederatedIdentityCredentials = "federated-identity-credentials"
)
@@ -444,6 +445,8 @@ func GetAZEntityInformation(ctx context.Context, db database.Database, graphDb g
return azure.RoleEntityDetails(ctx, graphDb, primaryDisplayKinds, objectID, hydrateCounts)
case entityTypeFunctionApps:
return azure.FunctionAppEntityDetails(ctx, graphDb, primaryDisplayKinds, objectID, hydrateCounts)
+ case entityTypeDomainServices:
+ return azure.DomainServiceEntityDetails(ctx, graphDb, primaryDisplayKinds, objectID, hydrateCounts)
case entityTypeFederatedIdentityCredentials:
return azure.FederatedIdentityCredentialEntityDetails(ctx, graphDb, primaryDisplayKinds, objectID, hydrateCounts)
default:
@@ -555,6 +558,9 @@ func azEntityParamToKind(entityType string) (graph.Kind, error) {
case entityTypeFunctionApps:
return azure_schema.FunctionApp, nil
+ case entityTypeDomainServices:
+ return azure_schema.EntraDS, nil
+
case entityTypeFederatedIdentityCredentials:
return azure_schema.FederatedIdentityCredential, nil
diff --git a/cmd/api/src/api/v2/azure_test.go b/cmd/api/src/api/v2/azure_test.go
index 565e7c17990f..f32e4dafb6e3 100644
--- a/cmd/api/src/api/v2/azure_test.go
+++ b/cmd/api/src/api/v2/azure_test.go
@@ -1120,6 +1120,36 @@ func TestResources_GetAZEntityInformation(t *testing.T) {
err: nil,
},
},
+ {
+ name: "Error: entityTypeDomainServices",
+ args: args{
+ entityType: "domain-services",
+ },
+ setupMocks: func(t *testing.T, mocks *mock) {
+ t.Helper()
+ mocks.mockDatabase.EXPECT().GetPrimaryDisplayKinds(gomock.Any())
+ mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error"))
+ },
+ want: want{
+ res: nil,
+ err: errors.New("error"),
+ },
+ },
+ {
+ name: "Success: entityTypeDomainServices",
+ args: args{
+ entityType: "domain-services",
+ },
+ setupMocks: func(t *testing.T, mocks *mock) {
+ t.Helper()
+ mocks.mockDatabase.EXPECT().GetPrimaryDisplayKinds(gomock.Any())
+ mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil)
+ },
+ want: want{
+ res: azure.DomainServiceDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0},
+ err: nil,
+ },
+ },
{
name: "Error: unknown azure entity",
args: args{
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 75d40e5c2c20..934ac3297722 100644
--- a/cmd/api/src/database/migration/extensions/az_graph_schema.sql
+++ b/cmd/api/src/database/migration/extensions/az_graph_schema.sql
@@ -161,6 +161,7 @@ BEGIN
PERFORM genscript_upsert_kind('AZRole');
PERFORM genscript_upsert_kind('AZDevice');
PERFORM genscript_upsert_kind('AZFunctionApp');
+ PERFORM genscript_upsert_kind('AZEntraDS');
PERFORM genscript_upsert_kind('AZGroup');
PERFORM genscript_upsert_kind('AZKeyVault');
PERFORM genscript_upsert_kind('AZManagementGroup');
@@ -181,6 +182,8 @@ BEGIN
PERFORM genscript_upsert_kind('AZAvereContributor');
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');
@@ -225,6 +228,12 @@ BEGIN
PERFORM genscript_upsert_kind('AZMGGrantAppRoles');
PERFORM genscript_upsert_kind('AZMGGrantRole');
PERFORM genscript_upsert_kind('SyncedToEntraUser');
+ PERFORM genscript_upsert_kind('SyncedToEntraDSUser');
+ PERFORM genscript_upsert_kind('SyncedToEntraDSGroup');
+ PERFORM genscript_upsert_kind('AddEntraDSGroupMember');
+ 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');
@@ -235,6 +244,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, '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');
@@ -257,6 +267,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('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"}}');
@@ -276,6 +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', '', 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);
@@ -320,6 +333,12 @@ BEGIN
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZMGGrantAppRoles', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'AZMGGrantRole', '', true);
PERFORM genscript_upsert_schema_relationship_kind(extension_id, 'SyncedToEntraUser', '', true);
+ 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, '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/cmd/api/src/services/graphify/azure_convertors.go b/cmd/api/src/services/graphify/azure_convertors.go
index aa4713c62d41..db7c4e3949a1 100644
--- a/cmd/api/src/services/graphify/azure_convertors.go
+++ b/cmd/api/src/services/graphify/azure_convertors.go
@@ -55,6 +55,10 @@ func getKindConverter(kind enums.Kind) func(json.RawMessage, *ConvertedAzureData
return convertAzureFunctionApp
case enums.KindAZFunctionAppRoleAssignment:
return convertAzureFunctionAppRoleAssignment
+ case enums.Kind("AZEntraDS"):
+ return convertAzureDomainService
+ case enums.Kind("AZEntraDSRoleAssignment"):
+ return convertAzureDomainServiceRoleAssignment
case enums.KindAZGroup:
return convertAzureGroup
case enums.KindAZGroupMember:
@@ -332,6 +336,33 @@ func convertAzureFunctionAppRoleAssignment(raw json.RawMessage, converted *Conve
}
}
+func convertAzureDomainService(raw json.RawMessage, converted *ConvertedAzureData, ingestTime time.Time) {
+ var data ein.AzureDomainService
+ if err := json.Unmarshal(raw, &data); err != nil {
+ slog.Error(
+ SerialError,
+ slog.String("type", "domain service"),
+ attr.Error(err),
+ )
+ } else {
+ converted.NodeProps = append(converted.NodeProps, ein.ConvertAzureDomainServiceToNode(data, ingestTime))
+ converted.RelProps = append(converted.RelProps, ein.ConvertAzureDomainServiceToRels(data)...)
+ }
+}
+
+func convertAzureDomainServiceRoleAssignment(raw json.RawMessage, converted *ConvertedAzureData, ingestTime time.Time) {
+ var data models.AzureRoleAssignments
+ if err := json.Unmarshal(raw, &data); err != nil {
+ slog.Error(
+ SerialError,
+ slog.String("type", "domain service role assignments"),
+ attr.Error(err),
+ )
+ } else {
+ converted.RelProps = append(converted.RelProps, ein.ConvertAzureDomainServiceRoleAssignmentToRels(data)...)
+ }
+}
+
func convertAzureGroup(raw json.RawMessage, converted *ConvertedAzureData, ingestTime time.Time) {
var data models.Group
if err := json.Unmarshal(raw, &data); err != nil {
diff --git a/cmd/ui/src/ducks/graph/graphutils.ts b/cmd/ui/src/ducks/graph/graphutils.ts
index b444568363f9..a387ba337b78 100644
--- a/cmd/ui/src/ducks/graph/graphutils.ts
+++ b/cmd/ui/src/ducks/graph/graphutils.ts
@@ -208,6 +208,7 @@ const ICONS: { [id in GraphNodeTypes]: string } = {
[GraphNodeTypes.AZRole]: 'fa-window-restore',
[GraphNodeTypes.AZDevice]: 'fa-desktop',
[GraphNodeTypes.AZFunctionApp]: 'fa-bolt',
+ [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 3ee5002d3ba2..e6f8d0ba5f1b 100644
--- a/cmd/ui/src/ducks/graph/types.ts
+++ b/cmd/ui/src/ducks/graph/types.ts
@@ -21,6 +21,7 @@ export enum GraphNodeTypes {
AZRole = 'AZRole',
AZDevice = 'AZDevice',
AZFunctionApp = 'AZFunctionApp',
+ AZEntraDS = 'AZEntraDS',
AZGroup = 'AZGroup',
AZKeyVault = 'AZKeyVault',
AZManagementGroup = 'AZManagementGroup',
diff --git a/packages/csharp/graphschema/PropertyNames.cs b/packages/csharp/graphschema/PropertyNames.cs
index 66c35f6af8fc..73ac845ccd28 100644
--- a/packages/csharp/graphschema/PropertyNames.cs
+++ b/packages/csharp/graphschema/PropertyNames.cs
@@ -123,6 +123,7 @@ public static class PropertyNames {
public static readonly string CertTemplateOID = "certtemplateoid";
public static readonly string GroupLinkID = "grouplinkid";
public static readonly string ObjectGUID = "objectguid";
+public static readonly string AADObjectID = "aadobjectid";
public static readonly string ExpirePasswordsOnSmartCardOnlyAccounts = "expirepasswordsonsmartcardonlyaccounts";
public static readonly string MachineAccountQuota = "machineaccountquota";
public static readonly string SupportedKerberosEncryptionTypes = "supportedencryptiontypes";
diff --git a/packages/cue/bh/ad/ad.cue b/packages/cue/bh/ad/ad.cue
index f7ff5f73a852..8c9c6036bdce 100644
--- a/packages/cue/bh/ad/ad.cue
+++ b/packages/cue/bh/ad/ad.cue
@@ -618,6 +618,13 @@ ObjectGUID: types.#StringEnum & {
representation: "objectguid"
}
+AADObjectID: types.#StringEnum & {
+ symbol: "AADObjectID"
+ schema: "ad"
+ name: "Microsoft Entra Object ID"
+ representation: "aadobjectid"
+}
+
ExpirePasswordsOnSmartCardOnlyAccounts: types.#StringEnum & {
symbol: "ExpirePasswordsOnSmartCardOnlyAccounts"
schema: "ad"
@@ -1120,6 +1127,7 @@ Properties: [
CertTemplateOID,
GroupLinkID,
ObjectGUID,
+ AADObjectID,
ExpirePasswordsOnSmartCardOnlyAccounts,
MachineAccountQuota,
SupportedKerberosEncryptionTypes,
diff --git a/packages/cue/bh/azure/azure.cue b/packages/cue/bh/azure/azure.cue
index fcc37983a8e3..bad6919c6a51 100644
--- a/packages/cue/bh/azure/azure.cue
+++ b/packages/cue/bh/azure/azure.cue
@@ -339,6 +339,125 @@ FederatedIdentityCredentialAppID: types.#StringEnum & {
representation: "federatedidentitycredentialappid"
}
+DomainName: types.#StringEnum & {
+ symbol: "DomainName"
+ schema: "azure"
+ name: "Domain Name"
+ representation: "domainname"
+}
+
+DomainConfigurationType: types.#StringEnum & {
+ symbol: "DomainConfigurationType"
+ schema: "azure"
+ name: "Domain Configuration Type"
+ representation: "domainconfigurationtype"
+}
+
+FilteredSyncEnabled: types.#StringEnum & {
+ symbol: "FilteredSyncEnabled"
+ schema: "azure"
+ name: "Filtered Sync Enabled"
+ representation: "filteredsyncenabled"
+}
+
+SyncScope: types.#StringEnum & {
+ symbol: "SyncScope"
+ schema: "azure"
+ name: "Sync Scope"
+ representation: "syncscope"
+}
+
+SyncApplicationID: types.#StringEnum & {
+ symbol: "SyncApplicationID"
+ schema: "azure"
+ name: "Sync Application ID"
+ representation: "syncapplicationid"
+}
+
+NTLMV1Enabled: types.#StringEnum & {
+ symbol: "NTLMV1Enabled"
+ schema: "azure"
+ name: "NTLM V1 Enabled"
+ representation: "ntlmv1enabled"
+}
+
+TLSV1Enabled: types.#StringEnum & {
+ symbol: "TLSV1Enabled"
+ schema: "azure"
+ name: "TLS V1 Enabled"
+ representation: "tlsv1enabled"
+}
+
+SyncNTLMPasswordsEnabled: types.#StringEnum & {
+ symbol: "SyncNTLMPasswordsEnabled"
+ schema: "azure"
+ name: "Sync NTLM Passwords Enabled"
+ representation: "syncntlmpasswordsenabled"
+}
+
+SyncKerberosPasswordsEnabled: types.#StringEnum & {
+ symbol: "SyncKerberosPasswordsEnabled"
+ schema: "azure"
+ name: "Sync Kerberos Passwords Enabled"
+ representation: "synckerberospasswordsenabled"
+}
+
+SyncOnPremPasswordsEnabled: types.#StringEnum & {
+ symbol: "SyncOnPremPasswordsEnabled"
+ schema: "azure"
+ name: "Sync On-Premises Passwords Enabled"
+ representation: "synconprempasswordsenabled"
+}
+
+KerberosRC4EncryptionEnabled: types.#StringEnum & {
+ symbol: "KerberosRC4EncryptionEnabled"
+ schema: "azure"
+ name: "Kerberos RC4 Encryption Enabled"
+ representation: "kerberosrc4encryptionenabled"
+}
+
+KerberosArmoringEnabled: types.#StringEnum & {
+ symbol: "KerberosArmoringEnabled"
+ schema: "azure"
+ name: "Kerberos Armoring Enabled"
+ representation: "kerberosarmoringenabled"
+}
+
+LDAPSigningEnabled: types.#StringEnum & {
+ symbol: "LDAPSigningEnabled"
+ schema: "azure"
+ name: "LDAP Signing Enabled"
+ representation: "ldapsigningenabled"
+}
+
+ChannelBindingEnabled: types.#StringEnum & {
+ symbol: "ChannelBindingEnabled"
+ schema: "azure"
+ name: "Channel Binding Enabled"
+ representation: "channelbindingenabled"
+}
+
+SyncOnPremSAMAccountNameEnabled: types.#StringEnum & {
+ symbol: "SyncOnPremSAMAccountNameEnabled"
+ schema: "azure"
+ name: "Sync On-Premises SAM Account Name Enabled"
+ representation: "synconpremsamaccountnameenabled"
+}
+
+LDAPSEnabled: types.#StringEnum & {
+ symbol: "LDAPSEnabled"
+ schema: "azure"
+ name: "Secure LDAP Enabled"
+ representation: "ldapsenabled"
+}
+
+LDAPSExternalAccessEnabled: types.#StringEnum & {
+ symbol: "LDAPSExternalAccessEnabled"
+ schema: "azure"
+ name: "Secure LDAP External Access Enabled"
+ representation: "ldapsexternalaccessenabled"
+}
+
Properties: [
AppOwnerOrganizationID,
AppDescription,
@@ -383,7 +502,24 @@ Properties: [
Issuer,
Subject,
Audiences,
- FederatedIdentityCredentialAppID
+ FederatedIdentityCredentialAppID,
+ DomainName,
+ DomainConfigurationType,
+ FilteredSyncEnabled,
+ SyncScope,
+ SyncApplicationID,
+ NTLMV1Enabled,
+ TLSV1Enabled,
+ SyncNTLMPasswordsEnabled,
+ SyncKerberosPasswordsEnabled,
+ SyncOnPremPasswordsEnabled,
+ KerberosRC4EncryptionEnabled,
+ KerberosArmoringEnabled,
+ LDAPSigningEnabled,
+ ChannelBindingEnabled,
+ SyncOnPremSAMAccountNameEnabled,
+ LDAPSEnabled,
+ LDAPSExternalAccessEnabled
]
// Kinds
@@ -423,6 +559,12 @@ FunctionApp: types.#Kind & {
representation: "AZFunctionApp"
}
+EntraDS: types.#Kind & {
+ symbol: "EntraDS"
+ schema: "azure"
+ representation: "AZEntraDS"
+}
+
Group: types.#Kind & {
symbol: "Group"
schema: "azure"
@@ -520,6 +662,7 @@ NodeKinds: [
Role,
Device,
FunctionApp,
+ EntraDS,
Group,
KeyVault,
ManagementGroup,
@@ -657,6 +800,18 @@ Contributor: types.#Kind & {
representation: "AZContributor"
}
+EntraDSContributor: types.#Kind & {
+ symbol: "EntraDSContributor"
+ schema: "azure"
+ representation: "AZEntraDSContributor"
+}
+
+ManageEntraDS: types.#Kind & {
+ symbol: "ManageEntraDS"
+ schema: "azure"
+ representation: "AZManageEntraDS"
+}
+
GetCertificates: types.#Kind & {
symbol: "GetCertificates"
schema: "azure"
@@ -818,6 +973,42 @@ SyncedToEntraUser: types.#Kind & {
schema: "azure"
}
+SyncedToEntraDSUser: types.#Kind & {
+ symbol: "SyncedToEntraDSUser"
+ schema: "azure"
+ representation: "SyncedToEntraDSUser"
+}
+
+SyncedToEntraDSGroup: types.#Kind & {
+ symbol: "SyncedToEntraDSGroup"
+ schema: "azure"
+ representation: "SyncedToEntraDSGroup"
+}
+
+AddEntraDSGroupMember: types.#Kind & {
+ symbol: "AddEntraDSGroupMember"
+ schema: "azure"
+ representation: "AddEntraDSGroupMember"
+}
+
+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: "ManageEntraDSSyncFilter"
+}
+
AZRoleEligible: types.#Kind & {
symbol: "AZRoleEligible"
schema: "azure"
@@ -840,6 +1031,8 @@ RelationshipKinds: [
AvereContributor,
Contains,
Contributor,
+ EntraDSContributor,
+ ManageEntraDS,
GetCertificates,
GetKeys,
GetSecrets,
@@ -884,6 +1077,12 @@ RelationshipKinds: [
AZMGGrantAppRoles,
AZMGGrantRole,
SyncedToEntraUser,
+ SyncedToEntraDSUser,
+ SyncedToEntraDSGroup,
+ AddEntraDSGroupMember,
+ EntraDSFor,
+ ManageEntraDSSync,
+ ManageEntraDSSyncFilter,
AZRoleEligible,
AZRoleApprover,
AZAuthenticatesTo
@@ -910,6 +1109,7 @@ AbusableAppRoleRelationshipKinds: [
ControlRelationshipKinds: [
AvereContributor,
Contributor,
+ ManageEntraDS,
Owner,
VMContributor,
AutomationContributor,
@@ -952,6 +1152,7 @@ ExecutionPrivilegeKinds: [
InboundOutboundRelationshipKinds: [
AvereContributor,
Contributor,
+ ManageEntraDS,
GetCertificates,
GetKeys,
GetSecrets,
@@ -988,6 +1189,10 @@ InboundOutboundRelationshipKinds: [
AZMGGrantAppRoles,
AZMGGrantRole,
SyncedToEntraUser,
+ SyncedToEntraDSUser,
+ AddEntraDSGroupMember,
+ ManageEntraDSSync,
+ ManageEntraDSSyncFilter,
AZRoleEligible,
AZRoleApprover,
Contains,
@@ -996,8 +1201,21 @@ InboundOutboundRelationshipKinds: [
PathfindingRelationships: list.Concat([InboundOutboundRelationshipKinds])
+EdgeCompositionRelationships: [
+ ManageEntraDS,
+ AddEntraDSGroupMember,
+ ManageEntraDSSync,
+]
+
PostProcessedRelationships: [
ExecuteCommand,
+ ManageEntraDS,
SyncedToEntraUser,
+ SyncedToEntraDSUser,
+ SyncedToEntraDSGroup,
+ AddEntraDSGroupMember,
+ 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/azure/azure_integration_test.go b/packages/go/analysis/azure/azure_integration_test.go
index efe3c323cbbe..733bf51157eb 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,109 @@ 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)
+
+ // 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)
+ 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.False(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/domain_service.go b/packages/go/analysis/azure/domain_service.go
new file mode 100644
index 000000000000..89a5e406d4de
--- /dev/null
+++ b/packages/go/analysis/azure/domain_service.go
@@ -0,0 +1,50 @@
+// 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"
+
+ "github.com/specterops/bloodhound/packages/go/graphschema"
+ "github.com/specterops/dawgs/graph"
+)
+
+func DomainServiceEntityDetails(ctx context.Context, db graph.Database, primaryDisplayKinds graphschema.PrimaryDisplayKinds, objectID string, hydrateCounts bool) (DomainServiceDetails, error) {
+ var details DomainServiceDetails
+
+ return details, db.ReadTransaction(ctx, func(tx graph.Transaction) error {
+ if node, err := FetchEntityByObjectID(tx, objectID); err != nil {
+ return err
+ } else {
+ details.Node = FromGraphNode(primaryDisplayKinds, node)
+ if hydrateCounts {
+ details, err = PopulateDomainServiceEntityDetailsCounts(tx, node, details)
+ }
+ return err
+ }
+ })
+}
+
+func PopulateDomainServiceEntityDetailsCounts(tx graph.Transaction, node *graph.Node, details DomainServiceDetails) (DomainServiceDetails, error) {
+ if inboundObjectControl, err := FetchInboundEntityObjectControllers(tx, node, 0, 0); err != nil {
+ return details, err
+ } else {
+ details.InboundObjectControl = inboundObjectControl.Len()
+ }
+
+ return details, nil
+}
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..68dac0bb2e29
--- /dev/null
+++ b/packages/go/analysis/azure/entra_domain_services.go
@@ -0,0 +1,359 @@
+// 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/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
+ }
+
+ applicationAdministratorPaths, err := getManageEntraDSRoleComposition(tx, domainService, source, azschema.ApplicationAdministratorRole)
+ if err != nil {
+ return err
+ } else if applicationAdministratorPaths.Len() == 0 {
+ return nil
+ }
+
+ groupsAdministratorPaths, err := getManageEntraDSRoleComposition(tx, domainService, 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 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
+ }
+
+ 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
+ }
+
+ principals, err := roleMembers(tx, roles)
+ if err != nil {
+ return nil, err
+ }
+ for _, role := range roles {
+ principals.Remove(role.ID)
+ }
+
+ return principals, nil
+}
+
+func getManageEntraDSRoleComposition(tx graph.Transaction, tenantScopedNode, source *graph.Node, roleTemplateID string) (graph.PathSet, error) {
+ finalPaths := graph.NewPathSet()
+ roles, err := getManageEntraDSRoles(tx, tenantScopedNode, 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
+ }
+
+ finalPaths.AddPathSet(rolePaths)
+ }
+
+ 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.
+// 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) {
+ 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 {
+ 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
+ }
+
+ for _, domainService := range domainServices {
+ controllers, err := effectiveEntraDSResourceControllers(tx, domainService)
+ if err != nil {
+ return err
+ }
+
+ for _, controller := range controllers {
+ if applicationAdministrators.ContainsID(controller.ID) && groupsAdministrators.ContainsID(controller.ID) {
+ 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/model.go b/packages/go/analysis/azure/model.go
index 53bbe80bd75e..affdd648c780 100644
--- a/packages/go/analysis/azure/model.go
+++ b/packages/go/analysis/azure/model.go
@@ -192,6 +192,12 @@ type FunctionAppDetails struct {
InboundObjectControl int `json:"inbound_object_control"`
}
+type DomainServiceDetails struct {
+ Node
+
+ InboundObjectControl int `json:"inbound_object_control"`
+}
+
type KeyVaultReaderCounts struct {
KeyReaders int `json:"KeyReaders"`
CertificateReaders int `json:"CertificateReaders"`
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 88de09d3e431..e46a597f06b7 100644
--- a/packages/go/analysis/hybrid/hybrid.go
+++ b/packages/go/analysis/hybrid/hybrid.go
@@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "strings"
"github.com/specterops/bloodhound/packages/go/analysis/post"
"github.com/specterops/bloodhound/packages/go/bhlog/attr"
@@ -34,6 +35,13 @@ import (
"github.com/specterops/dawgs/util/channels"
)
+const (
+ entraDSAdminGroupNamePrefix = "AAD DC ADMINISTRATORS@"
+ entraDSScopedSyncApplicationID = "2565BD9D-DA50-47D4-8B85-4C97F669DC36"
+ domainUsersObjectIDSuffix = "-513"
+ entraDSSyncScopeAll = "ALL"
+)
+
func fetchTenants(ctx context.Context, db graph.Database) (graph.NodeSet, error) {
var nodeSet graph.NodeSet
if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error {
@@ -77,7 +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)
+ 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
@@ -85,12 +102,13 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
// Fetch all users in this Entra tenant
if tenantUsers, err := fetchEntraUsers(tx, tenant); err != nil {
return err
- } else if len(tenantUsers) == 0 {
- // If there are no users present, exit this loop
- continue
} else {
// Loop through each Entra user in this tenant
for _, tenantUser := range tenantUsers {
+ if err := addNodeToObjectIDMap(entraDSUserAADObjectIDMap, tenantUser); err != nil {
+ return err
+ }
+
// Check to see if the Entra user has an on prem sync property set
if onPremID, hasOnPrem, err := hasOnPremUser(tenantUser); !hasOnPrem {
continue
@@ -102,6 +120,20 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
}
}
}
+
+ if tenantGroups, err := fetchEntraGroups(tx, tenant); err != nil {
+ return err
+ } else {
+ for _, tenantGroup := range tenantGroups {
+ if err := addNodeToObjectIDMap(entraDSGroupAADObjectIDMap, tenantGroup); err != nil {
+ return err
+ }
+
+ if err := addEntraDSAdminGroupTenant(entraDSAdminGroupTenantMap, tenantGroup); err != nil {
+ return err
+ }
+ }
+ }
}
// Because there's a chance for AD users to exist in the graph without having a valid domain node linked to them,
@@ -114,18 +146,50 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
// Get the user's Object ID
if objectID, err := adUser.Properties.Get(common.ObjectID.String()).String(); err != nil {
return err
- } else if azUsers, ok := adObjIDMap[objectID]; !ok {
- // Skip AD users that do not correspond to any synced Entra users.
- continue
- } else {
+ } else if azUsers, ok := adObjIDMap[objectID]; ok {
// Because there could theoretically be more than one Entra user mapped to this objectid, we want to loop through all when adding our current id to the final map
for _, azUser := range azUsers {
entraToADMap[azUser] = adUser.ID
}
}
+
+ if err := addSyncedToEntraDSEdges(syncedToEntraDSUserEdgeMap, adUser, entraDSUserAADObjectIDMap); err != nil {
+ return err
+ }
}
}
+ adGroups, err := fetchADGroups(tx)
+ if err != nil {
+ return err
+ }
+
+ for _, adGroup := range adGroups {
+ if err := addSyncedToEntraDSEdges(syncedToEntraDSGroupEdgeMap, adGroup, entraDSGroupAADObjectIDMap); err != nil {
+ return err
+ }
+ }
+
+ // 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 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 {
+ 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 {
for azUser, adUser := range entraToADMap {
SyncedToEntraUserRelationship := post.EnsureRelationshipJob{
@@ -149,23 +213,496 @@ func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcess
}
}
+ for adNode, azNodes := range syncedToEntraDSUserEdgeMap {
+ for _, azNode := range azNodes {
+ syncedToEntraDSUserRelationship := post.EnsureRelationshipJob{
+ FromID: azNode,
+ ToID: adNode,
+ Kind: azure.SyncedToEntraDSUser,
+ }
+
+ if !channels.Submit(ctx, outC, syncedToEntraDSUserRelationship) {
+ return nil
+ }
+ }
+ }
+
+ for adNode, azNodes := range syncedToEntraDSGroupEdgeMap {
+ for _, azNode := range azNodes {
+ syncedToEntraDSGroupRelationship := post.EnsureRelationshipJob{
+ FromID: azNode,
+ ToID: adNode,
+ Kind: azure.SyncedToEntraDSGroup,
+ }
+
+ if !channels.Submit(ctx, outC, syncedToEntraDSGroupRelationship) {
+ return nil
+ }
+ }
+ }
+
+ for azUser, adGroups := range addEntraDSGroupMemberEdgeMap {
+ for _, adGroup := range adGroups {
+ addEntraDSGroupMemberRelationship := post.EnsureRelationshipJob{
+ FromID: azUser,
+ ToID: adGroup,
+ Kind: azure.AddEntraDSGroupMember,
+ }
+
+ if !channels.Submit(ctx, outC, addEntraDSGroupMemberRelationship) {
+ return nil
+ }
+ }
+ }
+
+ 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 {
+ manageEntraDSSyncFilterRelationship := post.EnsureRelationshipJob{
+ FromID: sourceNode,
+ ToID: domainUserGroup,
+ Kind: azure.ManageEntraDSSyncFilter,
+ }
+
+ if !channels.Submit(ctx, outC, manageEntraDSSyncFilterRelationship) {
+ return nil
+ }
+ }
+ }
+
return nil
}); err != nil {
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
}
+func addNodeToObjectIDMap(nodeObjectIDMap map[string][]graph.ID, node *graph.Node) error {
+ if objectID, err := node.Properties.Get(common.ObjectID.String()).String(); err != nil {
+ return err
+ } else if normalizedObjectID := normalizeObjectID(objectID); len(normalizedObjectID) != 0 {
+ nodeObjectIDMap[normalizedObjectID] = append(nodeObjectIDMap[normalizedObjectID], node.ID)
+ }
+
+ return nil
+}
+
+func addSyncedToEntraDSEdges(edgeMap map[graph.ID][]graph.ID, adNode *graph.Node, azNodeMap map[string][]graph.ID) error {
+ if aadObjectID, hasAADObjectID, err := getEntraDSAADObjectID(adNode); err != nil {
+ return err
+ } else if !hasAADObjectID {
+ return nil
+ } else if azNodeIDs, ok := azNodeMap[aadObjectID]; ok {
+ edgeMap[adNode.ID] = append(edgeMap[adNode.ID], azNodeIDs...)
+ }
+
+ return nil
+}
+
+func addEntraDSAdminGroupTenant(entraDSAdminGroupTenantMap map[graph.ID]string, group *graph.Node) error {
+ if name, err := group.Properties.Get(common.Name.String()).String(); err != nil {
+ return err
+ } else if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(name)), entraDSAdminGroupNamePrefix) {
+ return nil
+ } else if tenantID, err := group.Properties.Get(azure.TenantID.String()).String(); err != nil {
+ return err
+ } else if normalizedTenantID := normalizeObjectID(tenantID); len(normalizedTenantID) != 0 {
+ entraDSAdminGroupTenantMap[group.ID] = normalizedTenantID
+ }
+
+ return nil
+}
+
+// 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)
+ 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
+
+ objectID, hasObjectID, err := normalizedNodeProperty(adGroup, common.ObjectID.String())
+ if err != nil {
+ return err
+ } else if !hasObjectID {
+ continue
+ }
+
+ 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 {
+ adAdminGroup, hasADAdminGroup := adGroupsByID[adAdminGroupID]
+ if !hasADAdminGroup {
+ continue
+ }
+
+ domainSID, hasDomainSID, err := normalizedNodeProperty(adAdminGroup, adSchema.DomainSID.String())
+ if err != nil {
+ return err
+ } else if !hasDomainSID {
+ continue
+ }
+
+ for _, azGroupID := range azGroupIDs {
+ if tenantID, isEntraDSAdminGroup := entraDSAdminGroupTenantMap[azGroupID]; isEntraDSAdminGroup {
+ if _, ok := adminGroupDomainSIDsByTenant[tenantID]; !ok {
+ adminGroupDomainSIDsByTenant[tenantID] = make(map[string]struct{})
+ }
+ adminGroupDomainSIDsByTenant[tenantID][domainSID] = struct{}{}
+ }
+ }
+ }
+
+ domains, err := fetchADDomains(tx)
+ if err != nil {
+ return fmt.Errorf("fetching AD domains: %w", 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)
+ if err != nil {
+ return fmt.Errorf("fetching Entra DS resources: %w", err)
+ }
+
+ for _, domainService := range domainServices {
+ tenantID, hasTenantID, err := normalizedNodeProperty(domainService, azure.TenantID.String())
+ if err != nil {
+ return err
+ } else if !hasTenantID {
+ continue
+ }
+
+ domainName, hasDomainName, err := normalizedNodeProperty(domainService, azure.DomainName.String())
+ if err != nil {
+ return err
+ } else if !hasDomainName {
+ continue
+ }
+
+ 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 fmt.Errorf("finding Domain Users containment for domain %d: %w", domain.ID, 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 fmt.Errorf("fetching Entra DS managers for resource %d: %w", domainService.ID, 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 {
+ scopedSyncTargetsByTenant[tenantID] = append(scopedSyncTargetsByTenant[tenantID], domainUserGroups...)
+ }
+ }
+
+ if len(scopedSyncTargetsByTenant) == 0 {
+ return nil
+ }
+
+ runsAsRelationships, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.And(
+ query.Kind(query.Relationship(), azure.RunsAs),
+ query.Kind(query.Start(), azure.App),
+ query.Kind(query.End(), azure.ServicePrincipal),
+ )
+ }))
+ if err != nil {
+ return fmt.Errorf("fetching Domain Controller Services application relationships: %w", err)
+ }
+
+ 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 fmt.Errorf("fetching endpoints for AZRunsAs relationship %d: %w", runsAsRelationship.ID, err)
+ }
+
+ applicationID, hasApplicationID, err := normalizedNodeProperty(application, common.ObjectID.String())
+ if err != nil {
+ return err
+ } else if !hasApplicationID || applicationID != entraDSScopedSyncApplicationID {
+ continue
+ }
+
+ servicePrincipalTenantID, hasTenantID, err := normalizedNodeProperty(servicePrincipal, azure.TenantID.String())
+ if err != nil {
+ return err
+ } else if !hasTenantID {
+ continue
+ }
+
+ 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
+ },
+ })
+ // 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
+ }
+
+ 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) {
+ filteredSyncEnabled, err := domainService.Properties.Get(azure.FilteredSyncEnabled.String()).Bool()
+ if errors.Is(err, graph.ErrPropertyNotFound) {
+ return false, nil
+ } else if err != nil {
+ return false, err
+ }
+
+ syncScope, err := domainService.Properties.Get(azure.SyncScope.String()).String()
+ if errors.Is(err, graph.ErrPropertyNotFound) {
+ return false, nil
+ } else if err != nil {
+ return false, err
+ }
+
+ return filteredSyncEnabled && normalizeObjectID(syncScope) == entraDSSyncScopeAll, nil
+}
+
+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{}{}
+ }
+
+ edgeMap[sourceNodeID] = append(edgeMap[sourceNodeID], targetNodeID)
+}
+
+// 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 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 {
+ // Build the set of AZUser node ids that are synced to Entra Domain Services
+ entraDSSyncedAZUsers := make(map[graph.ID]struct{}, len(syncedToEntraDSUserEdgeMap))
+ for _, azUserIDs := range syncedToEntraDSUserEdgeMap {
+ for _, azUserID := range azUserIDs {
+ entraDSSyncedAZUsers[azUserID] = struct{}{}
+ }
+ }
+
+ // Build a reverse mapping of Entra DS-synced AZGroup node ids to the on-prem Group node ids they are synced to
+ azGroupToADGroups := make(map[graph.ID][]graph.ID, len(syncedToEntraDSGroupEdgeMap))
+ for adGroupID, azGroupIDs := range syncedToEntraDSGroupEdgeMap {
+ for _, azGroupID := range azGroupIDs {
+ azGroupToADGroups[azGroupID] = append(azGroupToADGroups[azGroupID], adGroupID)
+ }
+ }
+
+ // No AddEntraDSGroupMember edges are possible unless there is at least one synced AZUser and one synced AZGroup
+ if len(entraDSSyncedAZUsers) == 0 || len(azGroupToADGroups) == 0 {
+ return nil
+ }
+
+ // Fetch all AZAddMembers / AZOwns relationships. Filtering the end node against azGroupToADGroups below naturally
+ // restricts these to relationships that target an Entra DS-synced AZGroup.
+ memberAddEdges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.KindIn(query.Relationship(), azure.AddMembers, azure.Owns)
+ }))
+ if err != nil {
+ return err
+ }
+
+ // Track emitted (AZUser, Group) pairs so an AZUser holding both AZOwns and AZAddMembers over the same group only
+ // yields a single edge
+ seen := make(map[string]struct{})
+ for _, edge := range memberAddEdges {
+ if _, ok := entraDSSyncedAZUsers[edge.StartID]; !ok {
+ continue
+ } else if adGroupIDs, ok := azGroupToADGroups[edge.EndID]; ok {
+ for _, adGroupID := range adGroupIDs {
+ key := edge.StartID.String() + "|" + adGroupID.String()
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ addEntraDSGroupMemberEdgeMap[edge.StartID] = append(addEntraDSGroupMemberEdgeMap[edge.StartID], adGroupID)
+ }
+ }
+ }
+
+ return nil
+}
+
+func getEntraDSAADObjectID(node *graph.Node) (string, bool, error) {
+ if aadObjectID, err := node.Properties.Get(adSchema.AADObjectID.String()).String(); errors.Is(err, graph.ErrPropertyNotFound) {
+ return "", false, nil
+ } else if err != nil {
+ return "", false, err
+ } else if normalizedAADObjectID := normalizeObjectID(aadObjectID); len(normalizedAADObjectID) == 0 {
+ return "", false, nil
+ } else {
+ return normalizedAADObjectID, true, nil
+ }
+}
+
+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) {
@@ -193,6 +730,29 @@ func fetchEntraUsers(tx graph.Transaction, root *graph.Node) (graph.NodeSet, err
}))
}
+// fetchEntraGroups fetches all the Entra groups for a given root node (generally the tenant node)
+func fetchEntraGroups(tx graph.Transaction, root *graph.Node) (graph.NodeSet, error) {
+ return ops.FetchEndNodes(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.And(
+ query.InIDs(query.StartID(), root.ID),
+ query.Kind(query.Relationship(), azure.Contains),
+ query.KindIn(query.End(), azure.Group),
+ )
+ }))
+}
+
+func fetchEntraDomainServices(tx graph.Transaction) ([]*graph.Node, error) {
+ return ops.FetchNodes(tx.Nodes().Filterf(func() graph.Criteria {
+ return query.Kind(query.Node(), azure.EntraDS)
+ }))
+}
+
+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 {
@@ -201,3 +761,12 @@ func fetchADUsers(tx graph.Transaction) ([]*graph.Node, error) {
)
}))
}
+
+// fetchADGroups gets all AD Groups in the graph
+func fetchADGroups(tx graph.Transaction) ([]*graph.Node, error) {
+ return ops.FetchNodes(tx.Nodes().Filterf(func() graph.Criteria {
+ return query.And(
+ query.Kind(query.Node(), adSchema.Group),
+ )
+ }))
+}
diff --git a/packages/go/analysis/hybrid/hybrid_integration_test.go b/packages/go/analysis/hybrid/hybrid_integration_test.go
index cb8f4e22f2eb..b40e134824e5 100644
--- a/packages/go/analysis/hybrid/hybrid_integration_test.go
+++ b/packages/go/analysis/hybrid/hybrid_integration_test.go
@@ -20,6 +20,7 @@ package hybrid
import (
"context"
+ "strings"
"testing"
"github.com/specterops/bloodhound/cmd/api/src/test/integration"
@@ -32,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) {
@@ -182,6 +184,811 @@ func TestHybridAttackPaths(t *testing.T) {
})
}
+func TestSyncedToEntraDSEdges(t *testing.T) {
+ t.Run("EdgesCreatedForMatchingADUserAndGroup", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ expectedEdges := []expectedSyncedToEntraDSEdge{}
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ tenantID := integration.RandomObjectID(t)
+ tenant := testContext.NewAzureTenant(tenantID)
+
+ azUserObjectID := integration.RandomObjectID(t)
+ azGroupObjectID := integration.RandomObjectID(t)
+ azUser := testContext.NewAzureUser("AZ User", "azuser@specter.dev", "", azUserObjectID, "", tenantID, false)
+ azGroup := testContext.NewAzureGroup("AZ Group", azGroupObjectID, tenantID)
+ testContext.NewRelationship(tenant, azUser, azure.Contains)
+ testContext.NewRelationship(tenant, azGroup, azure.Contains)
+
+ adUserObjectID := integration.RandomObjectID(t)
+ adGroupObjectID := integration.RandomObjectID(t)
+ testContext.NewCustomActiveDirectoryUser(graph.AsProperties(graph.PropertyMap{
+ common.Name: "ad_user",
+ common.ObjectID: adUserObjectID,
+ ad.DomainSID: integration.RandomDomainSID(),
+ ad.AADObjectID: strings.ToLower(azUserObjectID),
+ }))
+ testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "ad_group",
+ common.ObjectID: adGroupObjectID,
+ ad.DomainSID: integration.RandomDomainSID(),
+ ad.AADObjectID: strings.ToLower(azGroupObjectID),
+ }), ad.Entity, ad.Group)
+
+ expectedEdges = []expectedSyncedToEntraDSEdge{
+ {
+ startObjectID: azUserObjectID,
+ startKind: azure.User,
+ endObjectID: adUserObjectID,
+ endKind: ad.User,
+ kind: azure.SyncedToEntraDSUser,
+ },
+ {
+ startObjectID: azGroupObjectID,
+ startKind: azure.Group,
+ endObjectID: adGroupObjectID,
+ endKind: ad.Group,
+ kind: azure.SyncedToEntraDSGroup,
+ },
+ }
+
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for Entra DS sync edges: %v", err)
+ }
+
+ verifySyncedToEntraDSEdges(t, db, expectedEdges)
+ },
+ )
+ })
+
+ t.Run("EdgesNotCreatedAcrossMismatchedObjectTypes", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ tenantID := integration.RandomObjectID(t)
+ tenant := testContext.NewAzureTenant(tenantID)
+
+ azUserObjectID := integration.RandomObjectID(t)
+ azGroupObjectID := integration.RandomObjectID(t)
+ azUser := testContext.NewAzureUser("AZ User", "azuser@specter.dev", "", azUserObjectID, "", tenantID, false)
+ azGroup := testContext.NewAzureGroup("AZ Group", azGroupObjectID, tenantID)
+ testContext.NewRelationship(tenant, azUser, azure.Contains)
+ testContext.NewRelationship(tenant, azGroup, azure.Contains)
+
+ testContext.NewCustomActiveDirectoryUser(graph.AsProperties(graph.PropertyMap{
+ common.Name: "ad_user",
+ common.ObjectID: integration.RandomObjectID(t),
+ ad.DomainSID: integration.RandomDomainSID(),
+ ad.AADObjectID: azGroupObjectID,
+ }))
+ testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "ad_group",
+ common.ObjectID: integration.RandomObjectID(t),
+ ad.DomainSID: integration.RandomDomainSID(),
+ ad.AADObjectID: azUserObjectID,
+ }), ad.Entity, ad.Group)
+
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for Entra DS sync edges: %v", err)
+ }
+
+ verifySyncedToEntraDSEdges(t, db, nil)
+ },
+ )
+ })
+}
+
+type expectedSyncedToEntraDSEdge struct {
+ startObjectID string
+ startKind graph.Kind
+ endObjectID string
+ endKind graph.Kind
+ kind graph.Kind
+}
+
+func verifySyncedToEntraDSEdges(t *testing.T, db graph.Database, expectedEdges []expectedSyncedToEntraDSEdge) {
+ t.Helper()
+
+ expectedByObjectIDs := map[string]expectedSyncedToEntraDSEdge{}
+ for _, expectedEdge := range expectedEdges {
+ expectedByObjectIDs[expectedEdge.startObjectID+"|"+expectedEdge.endObjectID] = expectedEdge
+ }
+
+ db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
+ edges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.KindIn(query.Relationship(), azure.SyncedToEntraDSUser, azure.SyncedToEntraDSGroup)
+ }))
+ assert.Nil(t, err)
+ assert.Len(t, edges, len(expectedEdges))
+
+ for _, edge := range edges {
+ start, end, err := ops.FetchRelationshipNodes(tx, edge)
+ assert.Nil(t, err)
+
+ startObjectID, err := start.Properties.Get(common.ObjectID.String()).String()
+ assert.Nil(t, err)
+
+ endObjectID, err := end.Properties.Get(common.ObjectID.String()).String()
+ assert.Nil(t, err)
+
+ expectedEdge, ok := expectedByObjectIDs[startObjectID+"|"+endObjectID]
+ assert.True(t, ok)
+ assert.True(t, start.Kinds.ContainsOneOf(expectedEdge.startKind))
+ assert.True(t, end.Kinds.ContainsOneOf(expectedEdge.endKind))
+ assert.True(t, edge.Kind.Is(expectedEdge.kind))
+
+ delete(expectedByObjectIDs, startObjectID+"|"+endObjectID)
+ }
+
+ assert.Empty(t, expectedByObjectIDs)
+
+ return nil
+ })
+}
+
+func TestAddEntraDSGroupMemberEdge(t *testing.T) {
+ t.Run("EdgeCreatedViaAZAddMembers", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var azUserObjectID, adGroupObjectID string
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ azUser, _, _, adGroup := setupEntraDSGroupMemberHarness(t, testContext, azure.AddMembers, true, true)
+ azUserObjectID = getObjectID(t, azUser)
+ adGroupObjectID = getObjectID(t, adGroup)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for AddEntraDSGroupMember edge: %v", err)
+ }
+ verifyAddEntraDSGroupMemberEdge(t, db, azUserObjectID, adGroupObjectID, true)
+ },
+ )
+ })
+
+ t.Run("EdgeCreatedViaAZOwns", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var azUserObjectID, adGroupObjectID string
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ azUser, _, _, adGroup := setupEntraDSGroupMemberHarness(t, testContext, azure.Owns, true, true)
+ azUserObjectID = getObjectID(t, azUser)
+ adGroupObjectID = getObjectID(t, adGroup)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for AddEntraDSGroupMember edge: %v", err)
+ }
+ verifyAddEntraDSGroupMemberEdge(t, db, azUserObjectID, adGroupObjectID, true)
+ },
+ )
+ })
+
+ t.Run("EdgeNotCreatedWithoutControlEdge", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ // User and group both synced, but no AZOwns/AZAddMembers control edge
+ setupEntraDSGroupMemberHarness(t, testContext, graph.StringKind(""), true, true)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for AddEntraDSGroupMember edge: %v", err)
+ }
+ verifyAddEntraDSGroupMemberEdge(t, db, "", "", false)
+ },
+ )
+ })
+
+ t.Run("EdgeNotCreatedWhenGroupNotSynced", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ // User synced and has control over the group, but the AZGroup has no synced on-prem counterpart
+ setupEntraDSGroupMemberHarness(t, testContext, azure.AddMembers, true, false)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for AddEntraDSGroupMember edge: %v", err)
+ }
+ verifyAddEntraDSGroupMemberEdge(t, db, "", "", false)
+ },
+ )
+ })
+
+ t.Run("EdgeNotCreatedWhenUserNotSynced", func(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ // Group synced and user has control over the group, but the AZUser has no synced on-prem counterpart
+ setupEntraDSGroupMemberHarness(t, testContext, azure.AddMembers, false, true)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for AddEntraDSGroupMember edge: %v", err)
+ }
+ verifyAddEntraDSGroupMemberEdge(t, db, "", "", false)
+ },
+ )
+ })
+}
+
+func TestGetAddEntraDSGroupMemberEdgeComposition(t *testing.T) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var azUser, adUser, azGroup, adGroup *graph.Node
+
+ testContext.DatabaseTestWithSetup(
+ func(harness *integration.HarnessDetails) error {
+ azUser, adUser, azGroup, adGroup = setupEntraDSGroupMemberHarness(t, testContext, azure.AddMembers, true, true)
+ return nil
+ },
+ func(harness integration.HarnessDetails, db graph.Database) {
+ if _, err := PostHybrid(context.Background(), db); err != nil {
+ t.Fatalf("failed post processing for AddEntraDSGroupMember edge: %v", err)
+ }
+
+ // Grab the created AddEntraDSGroupMember edge and reconstruct its composition
+ var edge *graph.Relationship
+ db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
+ edges, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria {
+ return query.Kind(query.Relationship(), azure.AddEntraDSGroupMember)
+ }))
+ assert.Nil(t, err)
+ assert.Len(t, edges, 1)
+ edge = edges[0]
+ return nil
+ })
+
+ composition, err := GetAddEntraDSGroupMemberEdgeComposition(context.Background(), db, edge)
+ assert.Nil(t, err)
+
+ nodes := composition.AllNodes()
+ // The composition should include every object involved in the three composing paths
+ assert.True(t, nodes.Contains(azUser), "composition should contain the AZUser")
+ assert.True(t, nodes.Contains(adUser), "composition should contain the synced on-prem User")
+ assert.True(t, nodes.Contains(azGroup), "composition should contain the AZGroup")
+ assert.True(t, nodes.Contains(adGroup), "composition should contain the synced on-prem Group")
+ },
+ )
+}
+
+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,
+ },
+ {
+ 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 {
+ 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("AmbiguousDomainNameFailsClosed", 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())
+ 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) {
+ _, err := PostHybrid(context.Background(), db)
+ require.NoError(t, err)
+ verifyManageEntraDSSyncEdges(t, db, syncHarness, false, false, false)
+ },
+ )
+ })
+
+ 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) {
+ testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
+ var syncHarness manageEntraDSSyncHarness
+ var container *graph.Node
+ var unrelatedDomainService *graph.Node
+ var unrelatedDomain *graph.Node
+
+ 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)
+
+ 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
+ })
+ require.NoError(t, err)
+
+ 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))
+ },
+ )
+}
+
+type manageEntraDSSyncHarnessOptions struct {
+ applicationID string
+ adminGroupName string
+ manageDomainService bool
+ includeRunsAs bool
+ sameTenant bool
+ syncAdminGroup bool
+ matchingDomainName bool
+ matchingAdminGroupDomainSID bool
+ matchingDomainUsersSID bool
+ containDomainUsers bool
+ filteredSyncEnabled bool
+ syncScope string
+}
+
+type manageEntraDSSyncHarness struct {
+ domainService, application, servicePrincipal, manager, azAdminGroup, adAdminGroup, domain, domainUsers *graph.Node
+}
+
+func validManageEntraDSSyncOptions() manageEntraDSSyncHarnessOptions {
+ return manageEntraDSSyncHarnessOptions{
+ applicationID: entraDSScopedSyncApplicationID,
+ adminGroupName: entraDSAdminGroupNamePrefix + "SPECTER.DEV",
+ manageDomainService: true,
+ includeRunsAs: true,
+ sameTenant: true,
+ syncAdminGroup: true,
+ matchingDomainName: true,
+ matchingAdminGroupDomainSID: true,
+ matchingDomainUsersSID: true,
+ containDomainUsers: true,
+ filteredSyncEnabled: true,
+ syncScope: "All",
+ }
+}
+
+func setupManageEntraDSSyncHarness(t *testing.T, testContext *integration.GraphTestContext, options manageEntraDSSyncHarnessOptions) manageEntraDSSyncHarness {
+ t.Helper()
+
+ var (
+ tenantID = integration.RandomObjectID(t)
+ servicePrincipalTenantID = tenantID
+ domainSID = integration.RandomDomainSID()
+ adminGroupDomainSID = domainSID
+ domainUsersDomainSID = domainSID
+ domainName = "SPECTER.DEV"
+ domainServiceDomainName = " specter.dev "
+ )
+
+ if !options.sameTenant {
+ servicePrincipalTenantID = integration.RandomObjectID(t)
+ }
+ if !options.matchingDomainName {
+ domainServiceDomainName = "other.example"
+ }
+ if !options.matchingAdminGroupDomainSID {
+ adminGroupDomainSID = integration.RandomDomainSID()
+ }
+ if !options.matchingDomainUsersSID {
+ domainUsersDomainSID = integration.RandomDomainSID()
+ }
+
+ tenant := testContext.NewAzureTenant(tenantID)
+ servicePrincipalTenant := tenant
+ if !options.sameTenant {
+ servicePrincipalTenant = testContext.NewAzureTenant(servicePrincipalTenantID)
+ }
+
+ 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)
+ manager := testContext.NewAzureGroup("Managed Domain Manager", integration.RandomObjectID(t), tenantID)
+ azAdminGroupObjectID := integration.RandomObjectID(t)
+ azAdminGroup := testContext.NewAzureGroup(options.adminGroupName, azAdminGroupObjectID, tenantID)
+ 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)
+ if options.manageDomainService {
+ testContext.NewRelationship(manager, domainService, azure.ManageEntraDS)
+ }
+
+ adminGroupAADObjectID := integration.RandomObjectID(t)
+ if options.syncAdminGroup {
+ adminGroupAADObjectID = azAdminGroupObjectID
+ }
+
+ adAdminGroup := testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "AAD DC ADMINISTRATORS",
+ common.ObjectID: adminGroupDomainSID + "-1104",
+ ad.DomainSID: adminGroupDomainSID,
+ ad.AADObjectID: adminGroupAADObjectID,
+ }), ad.Entity, ad.Group)
+ 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 manageEntraDSSyncHarness{
+ domainService: domainService, application: application, servicePrincipal: servicePrincipal, manager: manager,
+ azAdminGroup: azAdminGroup, adAdminGroup: adAdminGroup, domain: domain, domainUsers: domainUsers,
+ }
+}
+
+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 {
+ 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
+ }
+
+ require.Len(t, edges, 1)
+ assert.Equal(t, expectation.start.ID, edges[0].StartID)
+ assert.Equal(t, expectation.end.ID, edges[0].EndID)
+ }
+
+ return nil
+ })
+}
+
+// setupEntraDSGroupMemberHarness builds an AZUser and AZGroup under a tenant with an optional control edge
+// (AZAddMembers / AZOwns) between them. When syncUser/syncGroup are true, matching on-prem AD User/Group nodes are
+// created (via ad.AADObjectID) so the corresponding SyncedToEntraDS edges are produced by PostHybrid. Pass an empty
+// kind as controlKind to omit the control edge entirely. Returns the AZUser, on-prem User, AZGroup, on-prem Group.
+func setupEntraDSGroupMemberHarness(t *testing.T, testContext *integration.GraphTestContext, controlKind graph.Kind, syncUser, syncGroup bool) (azUser, adUser, azGroup, adGroup *graph.Node) {
+ t.Helper()
+
+ tenantID := integration.RandomObjectID(t)
+ tenant := testContext.NewAzureTenant(tenantID)
+
+ azUserObjectID := integration.RandomObjectID(t)
+ azGroupObjectID := integration.RandomObjectID(t)
+ azUser = testContext.NewAzureUser("AZ User", "azuser@specter.dev", "", azUserObjectID, "", tenantID, false)
+ azGroup = testContext.NewAzureGroup("AZ Group", azGroupObjectID, tenantID)
+ testContext.NewRelationship(tenant, azUser, azure.Contains)
+ testContext.NewRelationship(tenant, azGroup, azure.Contains)
+
+ if controlKind.String() != "" {
+ testContext.NewRelationship(azUser, azGroup, controlKind)
+ }
+
+ if syncUser {
+ adUser = testContext.NewCustomActiveDirectoryUser(graph.AsProperties(graph.PropertyMap{
+ common.Name: "ad_user",
+ common.ObjectID: integration.RandomObjectID(t),
+ ad.DomainSID: integration.RandomDomainSID(),
+ ad.AADObjectID: strings.ToLower(azUserObjectID),
+ }))
+ }
+
+ if syncGroup {
+ adGroup = testContext.NewNode(graph.AsProperties(graph.PropertyMap{
+ common.Name: "ad_group",
+ common.ObjectID: integration.RandomObjectID(t),
+ ad.DomainSID: integration.RandomDomainSID(),
+ ad.AADObjectID: strings.ToLower(azGroupObjectID),
+ }), ad.Entity, ad.Group)
+ }
+
+ return azUser, adUser, azGroup, adGroup
+}
+
+func getObjectID(t *testing.T, node *graph.Node) string {
+ t.Helper()
+ objectID, err := node.Properties.Get(common.ObjectID.String()).String()
+ assert.Nil(t, err)
+ return objectID
+}
+
+func verifyAddEntraDSGroupMemberEdge(t *testing.T, db graph.Database, expectedStartObjectID, expectedEndObjectID string, shouldExist 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.AddEntraDSGroupMember)
+ }))
+ assert.Nil(t, err)
+
+ if !shouldExist {
+ assert.Len(t, edges, 0)
+ return nil
+ }
+
+ assert.Len(t, edges, 1)
+ for _, edge := range edges {
+ start, end, err := ops.FetchRelationshipNodes(tx, edge)
+ assert.Nil(t, err)
+
+ startObjectID, err := start.Properties.Get(common.ObjectID.String()).String()
+ assert.Nil(t, err)
+
+ endObjectID, err := end.Properties.Get(common.ObjectID.String()).String()
+ assert.Nil(t, err)
+
+ // AddEntraDSGroupMember is drawn from the AZUser to the on-prem Group
+ assert.True(t, start.Kinds.ContainsOneOf(azure.User))
+ assert.True(t, end.Kinds.ContainsOneOf(ad.Group))
+ assert.Equal(t, expectedStartObjectID, startObjectID)
+ assert.Equal(t, expectedEndObjectID, endObjectID)
+ }
+
+ return nil
+ })
+}
+
func verifyHybridPaths(t *testing.T, db graph.Database, harness integration.HarnessDetails, shouldHaveEdges bool) {
expectedEdgeCount := 1
if !shouldHaveEdges {
diff --git a/packages/go/analysis/hybrid/hybrid_test.go b/packages/go/analysis/hybrid/hybrid_test.go
new file mode 100644
index 000000000000..d133de972491
--- /dev/null
+++ b/packages/go/analysis/hybrid/hybrid_test.go
@@ -0,0 +1,48 @@
+// 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 (
+ "testing"
+
+ adSchema "github.com/specterops/bloodhound/packages/go/graphschema/ad"
+)
+
+func TestAADObjectIDPropertyMatchesCollectorContract(t *testing.T) {
+ const collectorProperty = "aadobjectid"
+
+ if actual := adSchema.AADObjectID.String(); actual != collectorProperty {
+ t.Fatalf("expected AADObjectID property %q, got %q", collectorProperty, actual)
+ }
+}
+
+func TestNormalizeObjectID(t *testing.T) {
+ for name, testCase := range map[string]struct {
+ input string
+ expected string
+ }{
+ "lowercase": {input: "69e33ede-7272-4893-ba72-18e6a92a0184", expected: "69E33EDE-7272-4893-BA72-18E6A92A0184"},
+ "surrounding spaces": {input: " 69e33ede-7272-4893-ba72-18e6a92a0184 ", expected: "69E33EDE-7272-4893-BA72-18E6A92A0184"},
+ "empty": {input: "", expected: ""},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if actual := normalizeObjectID(testCase.input); actual != testCase.expected {
+ t.Fatalf("expected normalized object ID %q, got %q", testCase.expected, actual)
+ }
+ })
+ }
+}
diff --git a/packages/go/analysis/post/post_integration_test.go b/packages/go/analysis/post/post_integration_test.go
index 8e9ebacb06a3..1828f3c7490e 100644
--- a/packages/go/analysis/post/post_integration_test.go
+++ b/packages/go/analysis/post/post_integration_test.go
@@ -64,6 +64,26 @@ func TestDeleteTransitEdges(t *testing.T) {
"name": "azure_user",
"objectid": "4321",
}), azure.Entity, azure.User)
+
+ adGroup = testCtx.NewNode(graph.AsProperties(map[string]any{
+ "name": "domain_users",
+ "objectid": "S-1-5-21-1-2-3-513",
+ }), ad.Entity, ad.Group)
+
+ domainService = testCtx.NewNode(graph.AsProperties(map[string]any{
+ "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
@@ -74,6 +94,11 @@ func TestDeleteTransitEdges(t *testing.T) {
// Here, we are choosing to create these edges such that the data describes what we would expect to see after a successful execution of the logic
// in bhce/cmd/api/src/analysis/azure/post.go.
testCtx.NewRelationship(adUser, azureUser, azure.SyncedToEntraUser)
+ testCtx.NewRelationship(azureUser, adUser, azure.SyncedToEntraDSUser)
+ 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
@@ -87,10 +112,22 @@ func TestDeleteTransitEdges(t *testing.T) {
err = testCtx.Graph.Database.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
numEdges, err := tx.Relationships().Filter(query.Kind(query.Relationship(), azure.SyncedToEntraUser)).Count()
+ require.Nil(t, err)
// This must be true which would mean that the above created SyncedToEntraUser was correctly deleted by the DeleteTransitEdges call
require.Equal(t, int64(0), numEdges)
- return err
+
+ numEdges, err = tx.Relationships().Filter(query.Kind(query.Relationship(), azure.SyncedToEntraDSUser)).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
})
// The DB must not return any errors
diff --git a/packages/go/ein/azure.go b/packages/go/ein/azure.go
index c03752efe234..eaeaed2006ae 100644
--- a/packages/go/ein/azure.go
+++ b/packages/go/ein/azure.go
@@ -2105,6 +2105,8 @@ func KindFromRoleId(roleId string) graph.Kind {
return azure.UserAccessAdministrator
case constants.ContributorRoleID:
return azure.Contributor
+ case constants.DomainServicesContributorRoleID:
+ 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
new file mode 100644
index 000000000000..2db3cc585538
--- /dev/null
+++ b/packages/go/ein/azure_domain_service.go
@@ -0,0 +1,160 @@
+// 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 ein
+
+import (
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/bloodhoundad/azurehound/v2/constants"
+ "github.com/bloodhoundad/azurehound/v2/models"
+ "github.com/specterops/bloodhound/packages/go/graphschema/azure"
+ "github.com/specterops/bloodhound/packages/go/graphschema/common"
+ "github.com/specterops/dawgs/graph"
+)
+
+type AzureDomainServiceLDAPSSettings struct {
+ LDAPS string `json:"ldaps"`
+ ExternalAccess string `json:"externalAccess"`
+}
+
+type AzureDomainServiceSecuritySettings struct {
+ NTLMV1 string `json:"ntlmV1"`
+ TLSV1 string `json:"tlsV1"`
+ SyncNTLMPasswords string `json:"syncNtlmPasswords"`
+ SyncKerberosPasswords string `json:"syncKerberosPasswords"`
+ SyncOnPremPasswords string `json:"syncOnPremPasswords"`
+ KerberosRC4Encryption string `json:"kerberosRc4Encryption"`
+ KerberosArmoring string `json:"kerberosArmoring"`
+ LDAPSigning string `json:"ldapSigning"`
+ ChannelBinding string `json:"channelBinding"`
+ SyncOnPremSAMAccountName string `json:"syncOnPremSamAccountName"`
+}
+
+type AzureDomainServiceProperties struct {
+ TenantID string `json:"tenantId"`
+ DomainName string `json:"domainName"`
+ DomainConfigurationType string `json:"domainConfigurationType"`
+ FilteredSync string `json:"filteredSync"`
+ SyncScope string `json:"syncScope"`
+ SyncApplicationID string `json:"syncApplicationId"`
+ DomainSecuritySettings AzureDomainServiceSecuritySettings `json:"domainSecuritySettings"`
+ LDAPSSettings AzureDomainServiceLDAPSSettings `json:"ldapsSettings"`
+}
+
+type AzureDomainService struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ResourceGroupID string `json:"resourceGroupId"`
+ ResourceGroupName string `json:"resourceGroupName"`
+ Properties AzureDomainServiceProperties `json:"properties"`
+}
+
+func ConvertAzureDomainServiceToNode(data AzureDomainService, ingestTime time.Time) 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.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 {
+ if data.ResourceGroupID == "" {
+ return nil
+ }
+
+ return []IngestibleRelationship{NewIngestibleRelationship(
+ IngestibleEndpoint{
+ Value: data.ResourceGroupID,
+ Kind: azure.ResourceGroup,
+ },
+ IngestibleEndpoint{
+ Value: data.ID,
+ Kind: azure.EntraDS,
+ },
+ IngestibleRel{
+ RelProps: map[string]any{},
+ RelType: azure.Contains,
+ },
+ )}
+}
+
+func ConvertAzureDomainServiceRoleAssignmentToRels(data models.AzureRoleAssignments) []IngestibleRelationship {
+ var relationships []IngestibleRelationship
+ allowedRoleIDs := []string{
+ constants.OwnerRoleID,
+ constants.UserAccessAdminRoleID,
+ constants.ContributorRoleID,
+ constants.DomainServicesContributorRoleID,
+ }
+
+ for _, roleAssignment := range data.RoleAssignments {
+ roleID := strings.ToLower(roleAssignment.RoleDefinitionId)
+ if strings.EqualFold(roleAssignment.Assignee.Properties.Scope, roleAssignment.ObjectId) && slices.Contains(allowedRoleIDs, roleID) {
+ relationships = append(relationships, NewIngestibleRelationship(
+ IngestibleEndpoint{
+ Value: roleAssignment.Assignee.GetPrincipalId(),
+ Kind: azure.Entity,
+ },
+ IngestibleEndpoint{
+ Value: data.ObjectId,
+ Kind: azure.EntraDS,
+ },
+ IngestibleRel{
+ RelProps: map[string]any{},
+ RelType: KindFromRoleId(roleID),
+ },
+ ))
+ }
+ }
+
+ return relationships
+}
diff --git a/packages/go/ein/azure_domain_service_test.go b/packages/go/ein/azure_domain_service_test.go
new file mode 100644
index 000000000000..2429e16bf362
--- /dev/null
+++ b/packages/go/ein/azure_domain_service_test.go
@@ -0,0 +1,180 @@
+// 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 ein_test
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/bloodhoundad/azurehound/v2/constants"
+ "github.com/bloodhoundad/azurehound/v2/models"
+ azure2 "github.com/bloodhoundad/azurehound/v2/models/azure"
+ "github.com/specterops/bloodhound/packages/go/ein"
+ "github.com/specterops/bloodhound/packages/go/graphschema/azure"
+ "github.com/specterops/bloodhound/packages/go/graphschema/common"
+ "github.com/specterops/dawgs/graph"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertAzureDomainServiceToNode(t *testing.T) {
+ ingestTime := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC)
+ data := ein.AzureDomainService{
+ ID: "/SUBSCRIPTIONS/SUB/RESOURCEGROUPS/RG/PROVIDERS/MICROSOFT.AAD/DOMAINSERVICES/EXAMPLE.COM",
+ Name: "example.com",
+ Properties: ein.AzureDomainServiceProperties{
+ TenantID: "6c12b0b0-b2cc-4a73-8252-0b94bfca2145",
+ DomainName: "example.com",
+ DomainConfigurationType: "FullySynced",
+ FilteredSync: "Enabled",
+ SyncScope: "CloudOnly",
+ SyncApplicationID: "75f5e42e-3d9f-472a-9d55-c387e29eacce",
+ DomainSecuritySettings: ein.AzureDomainServiceSecuritySettings{
+ NTLMV1: "Enabled",
+ TLSV1: "Disabled",
+ SyncNTLMPasswords: "Enabled",
+ SyncKerberosPasswords: "Enabled",
+ SyncOnPremPasswords: "Enabled",
+ KerberosRC4Encryption: "Enabled",
+ KerberosArmoring: "Disabled",
+ LDAPSigning: "Disabled",
+ ChannelBinding: "Disabled",
+ SyncOnPremSAMAccountName: "Disabled",
+ },
+ LDAPSSettings: ein.AzureDomainServiceLDAPSSettings{
+ LDAPS: "Enabled",
+ ExternalAccess: "Enabled",
+ },
+ },
+ }
+
+ node := ein.ConvertAzureDomainServiceToNode(data, ingestTime)
+
+ assert.Equal(t, data.ID, node.ObjectID)
+ 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()])
+ 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, 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, 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) {
+ data := ein.AzureDomainService{
+ ID: "/SUBSCRIPTIONS/SUB/RESOURCEGROUPS/RG/PROVIDERS/MICROSOFT.AAD/DOMAINSERVICES/EXAMPLE.COM",
+ ResourceGroupID: "/SUBSCRIPTIONS/SUB/RESOURCEGROUPS/RG",
+ }
+
+ rels := ein.ConvertAzureDomainServiceToRels(data)
+
+ require.Len(t, rels, 1)
+ 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.EntraDS, rels[0].Target.Kind)
+ assert.Equal(t, azure.Contains, rels[0].RelType)
+ assert.Empty(t, rels[0].RelProps)
+
+ data.ResourceGroupID = ""
+ assert.Empty(t, ein.ConvertAzureDomainServiceToRels(data))
+}
+
+func TestConvertAzureDomainServiceRoleAssignmentToRels(t *testing.T) {
+ resourceID := "/SUBSCRIPTIONS/SUB/RESOURCEGROUPS/RG/PROVIDERS/MICROSOFT.AAD/DOMAINSERVICES/EXAMPLE.COM"
+ principalID := "PRINCIPAL-ID"
+
+ testCases := []struct {
+ name string
+ roleDefinitionID string
+ scope string
+ expectedKind graph.Kind
+ expectedCount int
+ }{
+ {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.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},
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ data := models.AzureRoleAssignments{
+ ObjectId: resourceID,
+ RoleAssignments: []models.AzureRoleAssignment{{
+ ObjectId: resourceID,
+ RoleDefinitionId: strings.ToUpper(testCase.roleDefinitionID),
+ Assignee: azure2.RoleAssignment{Properties: azure2.RoleAssignmentPropertiesWithScope{
+ PrincipalId: principalID,
+ Scope: testCase.scope,
+ }},
+ }},
+ }
+
+ rels := ein.ConvertAzureDomainServiceRoleAssignmentToRels(data)
+
+ require.Len(t, rels, testCase.expectedCount)
+ if testCase.expectedCount == 1 {
+ 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.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/ad/ad.go b/packages/go/graphschema/ad/ad.go
index c1873329312b..bbcd980c70d7 100644
--- a/packages/go/graphschema/ad/ad.go
+++ b/packages/go/graphschema/ad/ad.go
@@ -216,6 +216,7 @@ const (
CertTemplateOID Property = "certtemplateoid"
GroupLinkID Property = "grouplinkid"
ObjectGUID Property = "objectguid"
+ AADObjectID Property = "aadobjectid"
ExpirePasswordsOnSmartCardOnlyAccounts Property = "expirepasswordsonsmartcardonlyaccounts"
MachineAccountQuota Property = "machineaccountquota"
SupportedKerberosEncryptionTypes Property = "supportedencryptiontypes"
@@ -277,7 +278,7 @@ const (
)
func AllProperties() []Property {
- return []Property{AdminCount, CASecurityCollected, CAName, CertChain, CertName, CertThumbprint, CertThumbprints, HasEnrollmentAgentRestrictions, EnrollmentAgentRestrictionsCollected, IsUserSpecifiesSanEnabled, IsUserSpecifiesSanEnabledCollected, RoleSeparationEnabled, RoleSeparationEnabledCollected, HasBasicConstraints, BasicConstraintPathLength, UnresolvedPublishedTemplates, DNSHostname, CrossCertificatePair, DistinguishedName, DomainFQDN, DomainSID, Sensitive, BlocksInheritance, IsACL, IsACLProtected, InheritanceHash, InheritanceHashes, IsDeleted, Enforced, Department, HasCrossCertificatePair, HasSPN, UnconstrainedDelegation, LastLogon, LastLogonTimestamp, IsPrimaryGroup, HasLAPS, DontRequirePreAuth, LogonType, HasURA, PasswordNeverExpires, PasswordNotRequired, FunctionalLevel, TrustType, SpoofSIDHistoryBlocked, TrustedToAuth, SamAccountName, CertificateMappingMethodsRaw, CertificateMappingMethods, StrongCertificateBindingEnforcementRaw, StrongCertificateBindingEnforcement, VulnerableNetlogonSecurityDescriptor, VulnerableNetlogonSecurityDescriptorCollected, EKUs, SubjectAltRequireUPN, SubjectAltRequireDNS, SubjectAltRequireDomainDNS, SubjectAltRequireEmail, SubjectAltRequireSPN, SubjectRequireEmail, AuthorizedSignatures, ApplicationPolicies, IssuancePolicies, SchemaVersion, RequiresManagerApproval, AuthenticationEnabled, SchannelAuthenticationEnabled, EnrolleeSuppliesSubject, CertificateApplicationPolicy, CertificateNameFlag, EffectiveEKUs, EnrollmentFlag, Flags, NoSecurityExtension, RenewalPeriod, ValidityPeriod, OID, HomeDirectory, CertificatePolicy, CertTemplateOID, GroupLinkID, ObjectGUID, ExpirePasswordsOnSmartCardOnlyAccounts, MachineAccountQuota, SupportedKerberosEncryptionTypes, TGTDelegation, PasswordStoredUsingReversibleEncryption, SmartcardRequired, UseDESKeyOnly, LogonScriptEnabled, LockedOut, UserCannotChangePassword, PasswordExpired, DSHeuristics, UserAccountControl, TrustAttributesInbound, TrustAttributesOutbound, MinPwdLength, PwdProperties, PwdHistoryLength, LockoutThreshold, MinPwdAge, MaxPwdAge, LockoutDuration, LockoutObservationWindow, OwnerSid, SMBSigning, WebClientRunning, RestrictOutboundNTLM, GMSA, MSA, DoesAnyAceGrantOwnerRights, DoesAnyInheritedAceGrantOwnerRights, ADCSWebEnrollmentHTTP, ADCSWebEnrollmentHTTPS, ADCSWebEnrollmentHTTPSEPA, LDAPSigning, LDAPAvailable, LDAPSAvailable, LDAPSEPA, IsDC, IsReadOnlyDC, HTTPEnrollmentEndpoints, HTTPSEnrollmentEndpoints, HasVulnerableEndpoint, RequireSecuritySignature, EnableSecuritySignature, RestrictReceivingNTLMTraffic, NTLMMinServerSec, NTLMMinClientSec, LMCompatibilityLevel, UseMachineID, ClientAllowedNTLMServers, Transitive, GroupScope, NetBIOS, AdminSDHolderProtected, ServicePrincipalNames, GPOStatusRaw, GPOStatus}
+ return []Property{AdminCount, CASecurityCollected, CAName, CertChain, CertName, CertThumbprint, CertThumbprints, HasEnrollmentAgentRestrictions, EnrollmentAgentRestrictionsCollected, IsUserSpecifiesSanEnabled, IsUserSpecifiesSanEnabledCollected, RoleSeparationEnabled, RoleSeparationEnabledCollected, HasBasicConstraints, BasicConstraintPathLength, UnresolvedPublishedTemplates, DNSHostname, CrossCertificatePair, DistinguishedName, DomainFQDN, DomainSID, Sensitive, BlocksInheritance, IsACL, IsACLProtected, InheritanceHash, InheritanceHashes, IsDeleted, Enforced, Department, HasCrossCertificatePair, HasSPN, UnconstrainedDelegation, LastLogon, LastLogonTimestamp, IsPrimaryGroup, HasLAPS, DontRequirePreAuth, LogonType, HasURA, PasswordNeverExpires, PasswordNotRequired, FunctionalLevel, TrustType, SpoofSIDHistoryBlocked, TrustedToAuth, SamAccountName, CertificateMappingMethodsRaw, CertificateMappingMethods, StrongCertificateBindingEnforcementRaw, StrongCertificateBindingEnforcement, VulnerableNetlogonSecurityDescriptor, VulnerableNetlogonSecurityDescriptorCollected, EKUs, SubjectAltRequireUPN, SubjectAltRequireDNS, SubjectAltRequireDomainDNS, SubjectAltRequireEmail, SubjectAltRequireSPN, SubjectRequireEmail, AuthorizedSignatures, ApplicationPolicies, IssuancePolicies, SchemaVersion, RequiresManagerApproval, AuthenticationEnabled, SchannelAuthenticationEnabled, EnrolleeSuppliesSubject, CertificateApplicationPolicy, CertificateNameFlag, EffectiveEKUs, EnrollmentFlag, Flags, NoSecurityExtension, RenewalPeriod, ValidityPeriod, OID, HomeDirectory, CertificatePolicy, CertTemplateOID, GroupLinkID, ObjectGUID, AADObjectID, ExpirePasswordsOnSmartCardOnlyAccounts, MachineAccountQuota, SupportedKerberosEncryptionTypes, TGTDelegation, PasswordStoredUsingReversibleEncryption, SmartcardRequired, UseDESKeyOnly, LogonScriptEnabled, LockedOut, UserCannotChangePassword, PasswordExpired, DSHeuristics, UserAccountControl, TrustAttributesInbound, TrustAttributesOutbound, MinPwdLength, PwdProperties, PwdHistoryLength, LockoutThreshold, MinPwdAge, MaxPwdAge, LockoutDuration, LockoutObservationWindow, OwnerSid, SMBSigning, WebClientRunning, RestrictOutboundNTLM, GMSA, MSA, DoesAnyAceGrantOwnerRights, DoesAnyInheritedAceGrantOwnerRights, ADCSWebEnrollmentHTTP, ADCSWebEnrollmentHTTPS, ADCSWebEnrollmentHTTPSEPA, LDAPSigning, LDAPAvailable, LDAPSAvailable, LDAPSEPA, IsDC, IsReadOnlyDC, HTTPEnrollmentEndpoints, HTTPSEnrollmentEndpoints, HasVulnerableEndpoint, RequireSecuritySignature, EnableSecuritySignature, RestrictReceivingNTLMTraffic, NTLMMinServerSec, NTLMMinClientSec, LMCompatibilityLevel, UseMachineID, ClientAllowedNTLMServers, Transitive, GroupScope, NetBIOS, AdminSDHolderProtected, ServicePrincipalNames, GPOStatusRaw, GPOStatus}
}
func ParseProperty(source string) (Property, error) {
switch source {
@@ -445,6 +446,8 @@ func ParseProperty(source string) (Property, error) {
return GroupLinkID, nil
case "objectguid":
return ObjectGUID, nil
+ case "aadobjectid":
+ return AADObjectID, nil
case "expirepasswordsonsmartcardonlyaccounts":
return ExpirePasswordsOnSmartCardOnlyAccounts, nil
case "machineaccountquota":
@@ -731,6 +734,8 @@ func (s Property) String() string {
return string(GroupLinkID)
case ObjectGUID:
return string(ObjectGUID)
+ case AADObjectID:
+ return string(AADObjectID)
case ExpirePasswordsOnSmartCardOnlyAccounts:
return string(ExpirePasswordsOnSmartCardOnlyAccounts)
case MachineAccountQuota:
@@ -1017,6 +1022,8 @@ func (s Property) Name() string {
return "Group Link ID"
case ObjectGUID:
return "Object GUID"
+ case AADObjectID:
+ return "Microsoft Entra Object ID"
case ExpirePasswordsOnSmartCardOnlyAccounts:
return "Expire Passwords on Smart Card only Accounts"
case MachineAccountQuota:
diff --git a/packages/go/graphschema/azure/azure.go b/packages/go/graphschema/azure/azure.go
index 05cdde555a88..2f685eeecd44 100644
--- a/packages/go/graphschema/azure/azure.go
+++ b/packages/go/graphschema/azure/azure.go
@@ -31,6 +31,7 @@ var (
Role = graph.StringKind("AZRole")
Device = graph.StringKind("AZDevice")
FunctionApp = graph.StringKind("AZFunctionApp")
+ EntraDS = graph.StringKind("AZEntraDS")
Group = graph.StringKind("AZGroup")
KeyVault = graph.StringKind("AZKeyVault")
ManagementGroup = graph.StringKind("AZManagementGroup")
@@ -49,6 +50,8 @@ var (
AvereContributor = graph.StringKind("AZAvereContributor")
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")
@@ -93,6 +96,12 @@ var (
AZMGGrantAppRoles = graph.StringKind("AZMGGrantAppRoles")
AZMGGrantRole = graph.StringKind("AZMGGrantRole")
SyncedToEntraUser = graph.StringKind("SyncedToEntraUser")
+ SyncedToEntraDSUser = graph.StringKind("SyncedToEntraDSUser")
+ SyncedToEntraDSGroup = graph.StringKind("SyncedToEntraDSGroup")
+ AddEntraDSGroupMember = graph.StringKind("AddEntraDSGroupMember")
+ EntraDSFor = graph.StringKind("EntraDSFor")
+ ManageEntraDSSync = graph.StringKind("ManageEntraDSSync")
+ ManageEntraDSSyncFilter = graph.StringKind("ManageEntraDSSyncFilter")
AZRoleEligible = graph.StringKind("AZRoleEligible")
AZRoleApprover = graph.StringKind("AZRoleApprover")
AZAuthenticatesTo = graph.StringKind("AZAuthenticatesTo")
@@ -145,10 +154,27 @@ const (
Subject Property = "subject"
Audiences Property = "audiences"
FederatedIdentityCredentialAppID Property = "federatedidentitycredentialappid"
+ DomainName Property = "domainname"
+ DomainConfigurationType Property = "domainconfigurationtype"
+ FilteredSyncEnabled Property = "filteredsyncenabled"
+ SyncScope Property = "syncscope"
+ SyncApplicationID Property = "syncapplicationid"
+ 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}
+ 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 {
@@ -240,6 +266,40 @@ func ParseProperty(source string) (Property, error) {
return Audiences, nil
case "federatedidentitycredentialappid":
return FederatedIdentityCredentialAppID, nil
+ case "domainname":
+ return DomainName, nil
+ case "domainconfigurationtype":
+ return DomainConfigurationType, nil
+ case "filteredsyncenabled":
+ return FilteredSyncEnabled, nil
+ case "syncscope":
+ return SyncScope, nil
+ case "syncapplicationid":
+ return SyncApplicationID, 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)
}
@@ -334,6 +394,40 @@ func (s Property) String() string {
return string(Audiences)
case FederatedIdentityCredentialAppID:
return string(FederatedIdentityCredentialAppID)
+ case DomainName:
+ return string(DomainName)
+ case DomainConfigurationType:
+ return string(DomainConfigurationType)
+ case FilteredSyncEnabled:
+ return string(FilteredSyncEnabled)
+ case SyncScope:
+ return string(SyncScope)
+ case SyncApplicationID:
+ return string(SyncApplicationID)
+ 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)
}
@@ -428,6 +522,40 @@ func (s Property) Name() string {
return "Audiences"
case FederatedIdentityCredentialAppID:
return "Federated Identity Credential Application ID"
+ case DomainName:
+ return "Domain Name"
+ case DomainConfigurationType:
+ return "Domain Configuration Type"
+ case FilteredSyncEnabled:
+ return "Filtered Sync Enabled"
+ case SyncScope:
+ return "Sync Scope"
+ case SyncApplicationID:
+ return "Sync Application ID"
+ 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)
}
@@ -441,7 +569,7 @@ func (s Property) Is(others ...graph.Kind) bool {
return false
}
func Relationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contains, Contributor, 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, 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}
@@ -450,17 +578,17 @@ func AbusableAppRoleRelationshipKinds() []graph.Kind {
return []graph.Kind{ApplicationReadWriteAll, AppRoleAssignmentReadWriteAll, DirectoryReadWriteAll, GroupReadWriteAll, GroupMemberReadWriteAll, RoleManagementReadWriteDirectory, ServicePrincipalEndpointReadWriteAll}
}
func ControlRelationships() []graph.Kind {
- return []graph.Kind{AvereContributor, Contributor, 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, 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, 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, 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, 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/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 58be06fdf43b..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.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.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.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.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/sql.go b/packages/go/schemagen/generator/sql.go
index a85f272cf580..a6e371392c5f 100644
--- a/packages/go/schemagen/generator/sql.go
+++ b/packages/go/schemagen/generator/sql.go
@@ -125,6 +125,10 @@ var nodeIcons = map[string]nodeIcon{
Icon: "bolt",
Color: "#F4BA44",
},
+ "AZEntraDS": {
+ Icon: "server",
+ Color: "#6D83F2",
+ },
"AZContainerRegistry": {
Icon: "box-open",
Color: "#0885D7",
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/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 {
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/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 d8d8c516ac23..260d2ff738df 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,27 @@ 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: '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: '',
+ 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 +515,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|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|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 92d06fd1cc12..3ea225cc3649 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,27 @@ 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: '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: '',
+ 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 +515,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|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|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/components/HelpTexts/AZContains/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContains/General.tsx
index 33a7532a6f95..70a26fa13bcf 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContains/General.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContains/General.tsx
@@ -21,7 +21,7 @@ const General: FC = () => {
return (
This indicates that the parent object contains the child object, such as a resource group containing a
- virtual machine, or a tenant "containing" a subscription.
+ virtual machine or Microsoft Entra Domain Services managed domain, or a tenant "containing" a subscription.
);
};
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 b6807ced6926..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
@@ -35,6 +35,13 @@ const Abuse: FC = () => {
Virtual Machine: Run SYSTEM commands on the VM
+
+ Microsoft Entra Domain Services: Contributor supplies the Azure Resource Manager
+ 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/AZContributor/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/References.tsx
index 8ac96daca009..c5fd24e68553 100644
--- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/References.tsx
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZContributor/References.tsx
@@ -41,6 +41,20 @@ const References: FC = () => {
https://blog.netspi.com/attacking-azure-cloud-shell/
+
+
+ Microsoft.AAD/domainServices reference
+
+
+
+ Secure a Microsoft Entra Domain Services managed domain
+
);
};
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/AZEntraDSContributor.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/AZEntraDSContributor.tsx
new file mode 100644
index 000000000000..9671abbf9cc3
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/AZEntraDSContributor.tsx
@@ -0,0 +1,29 @@
+// 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 Abuse from './Abuse';
+import General from './General';
+import Opsec from './Opsec';
+import References from './References';
+
+const AZEntraDSContributor = {
+ general: General,
+ abuse: Abuse,
+ opsec: Opsec,
+ references: References,
+};
+
+export default AZEntraDSContributor;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Abuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Abuse.tsx
new file mode 100644
index 000000000000..0b0f5ca1e443
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Abuse.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 Abuse: FC = () => {
+ return (
+
+ 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 Abuse;
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
new file mode 100644
index 000000000000..c138f25d54c1
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/General.tsx
@@ -0,0 +1,37 @@
+// 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 (
+ <>
+
+ 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.
+
+ >
+ );
+};
+
+export default General;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Opsec.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/Opsec.tsx
new file mode 100644
index 000000000000..16bf7d8175e2
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/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 (
+
+ Azure records managed-domain updates in the Activity Log. Synchronization-scope changes can also generate
+ Microsoft Entra audit activity and trigger a full managed-domain resynchronization, including deletion of
+ objects that fall out of scope.
+
+ );
+};
+
+export default Opsec;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/References.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/References.tsx
new file mode 100644
index 000000000000..e081a5ebdf8e
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZEntraDSContributor/References.tsx
@@ -0,0 +1,58 @@
+// 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 { FC } from 'react';
+
+const References: FC = () => {
+ return (
+
+
+ Domain Services Contributor built-in role
+
+
+
+ Microsoft.AAD/domainServices reference
+
+
+
+ Harden a Microsoft Entra Domain Services managed domain
+
+
+
+ Configure scoped synchronization
+
+
+
+ MITRE ATT&CK T1484: Domain or Tenant Policy Modification
+
+
+ );
+};
+
+export default References;
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..10c6ba4b0b68
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/AZManageEntraDS.tsx
@@ -0,0 +1,73 @@
+// 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 = () => (
+
+ 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 when all three authorization components are 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..fb5ad38b4480
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZManageEntraDS/Composition.tsx
@@ -0,0 +1,53 @@
+// 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. All
+ three permission components must apply to the same source principal. Tenant-to-role containment is
+ neither required nor included in this composition.
+
+
+ {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 abd84e2a1032..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,6 +39,12 @@ const AZVMLink = (
);
+const AZEntraDSLink = (
+
+ AZEntraDS
+
+);
+
const General: FC = () => {
return (
- 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 {AZEntraDSLink}) 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..e7150ff20be6
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/Composition.test.tsx
@@ -0,0 +1,81 @@
+// 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('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_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
new file mode 100644
index 000000000000..c15dbac3b7e7
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/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 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..391bc6736c02
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/General.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 { Typography } from 'doodle-ui';
+import { FC } from 'react';
+
+const General: FC = () => {
+ return (
+ <>
+
+ 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: 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.
+
+
+ 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.
+
+
+ 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..f054812bdba1
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/LinuxAbuse.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 Abuse: FC = () => {
+ return (
+
+
+
+ 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.
+
+
+
+ );
+};
+
+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..79d1d954b1ee
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/References.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 { 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',
+ },
+ {
+ label: 'MITRE ATT&CK T1098.007: Account Manipulation - Additional Local or Domain Groups',
+ link: 'https://attack.mitre.org/techniques/T1098/007/',
+ },
+ ];
+ 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..aed87a20b758
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AddEntraDSGroupMember/WindowsAbuse.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 Abuse: FC = () => {
+ return (
+
+
+
+ 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.
+
+
+
+ );
+};
+
+export default Abuse;
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..618a58d4e1c2
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/EntraDSFor/EntraDSFor.tsx
@@ -0,0 +1,46 @@
+// 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 = () => (
+ <>
+
+ 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 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/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..78c80324215f
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSync/ManageEntraDSSync.tsx
@@ -0,0 +1,83 @@
+// 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 = () => (
+ <>
+
+ 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 = () => (
+
+
+
+ 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
+
+
+
+ Configure scoped synchronization
+
+
+
+ MITRE ATT&CK T1136.002: Create Account - Domain Account
+
+
+);
+
+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..9ebcf880a933
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ManageEntraDSSyncFilter/ManageEntraDSSyncFilter.tsx
@@ -0,0 +1,96 @@
+// 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 = () => (
+ <>
+
+ 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 = () => (
+
+
+
+ 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
+
+
+
+ Microsoft Entra Domain Services synchronization
+
+
+
+ Microsoft Graph appRoleAssignedTo resource
+
+
+);
+
+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/SyncedToEntraDSGroup/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
new file mode 100644
index 000000000000..4cfc81870c74
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/General.tsx
@@ -0,0 +1,45 @@
+// Copyright 2024 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 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 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 DS groups through
+ this relationship.
+
+
+ This relationship is informational. Control of the Entra group does not by itself provide a usable Entra
+ 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.
+
+ >
+ );
+};
+
+export default General;
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
new file mode 100644
index 000000000000..113ce0e7c0d6
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/References.tsx
@@ -0,0 +1,47 @@
+// Copyright 2024 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: 'MITRE ATT&CK T1098.007: Account Manipulation - Additional Local or Domain Groups',
+ link: 'https://attack.mitre.org/techniques/T1098/007/',
+ },
+ ];
+ return (
+
+ {references.map((reference) => {
+ return (
+
+
+ {reference.label}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default References;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/SyncedToEntraDSGroup.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/SyncedToEntraDSGroup.tsx
new file mode 100644
index 000000000000..2c0375e2ad2a
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSGroup/SyncedToEntraDSGroup.tsx
@@ -0,0 +1,25 @@
+// Copyright 2024 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 References from './References';
+
+const SyncedToEntraDSGroup = {
+ general: General,
+ references: References,
+};
+
+export default SyncedToEntraDSGroup;
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
new file mode 100644
index 000000000000..ceb39e26d341
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/General.tsx
@@ -0,0 +1,48 @@
+// Copyright 2024 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 BloodHound correlated a Microsoft Entra user with a user in a Microsoft
+ Entra Domain Services (Entra DS) 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
+ 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.
+
+ >
+ );
+};
+
+export default General;
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
new file mode 100644
index 000000000000..9e2c1fdfc0ac
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/LinuxAbuse.tsx
@@ -0,0 +1,50 @@
+// Copyright 2024 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 (
+
+ 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.
+
+
+
+ );
+};
+
+export default Abuse;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/Opsec.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/Opsec.tsx
new file mode 100644
index 000000000000..e01d1572e71a
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/Opsec.tsx
@@ -0,0 +1,30 @@
+// Copyright 2024 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 (
+
+ Authenticating as the Entra Domain Services user may create Windows Logon Events and entries in log
+ solutions if Azure Monitor Diagnostic settings are enabled for Entra Domain Services. Changing the Entra
+ user's password may also create Entra ID audit logs.
+
+ );
+};
+
+export default Opsec;
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
new file mode 100644
index 000000000000..eb1e4717d7bd
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/References.tsx
@@ -0,0 +1,47 @@
+// Copyright 2024 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: 'MITRE ATT&CK T1078.002: Valid Accounts - Domain Accounts',
+ link: 'https://attack.mitre.org/techniques/T1078/002/',
+ },
+ ];
+ return (
+
+ {references.map((reference) => {
+ return (
+
+
+ {reference.label}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default References;
diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/SyncedToEntraDSUser.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/SyncedToEntraDSUser.tsx
new file mode 100644
index 000000000000..46632450ab92
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/SyncedToEntraDSUser.tsx
@@ -0,0 +1,31 @@
+// Copyright 2024 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 SyncedToEntraDSUser = {
+ general: General,
+ windowsAbuse: WindowsAbuse,
+ linuxAbuse: LinuxAbuse,
+ opsec: Opsec,
+ references: References,
+};
+
+export default SyncedToEntraDSUser;
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
new file mode 100644
index 000000000000..9e2c1fdfc0ac
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/SyncedToEntraDSUser/WindowsAbuse.tsx
@@ -0,0 +1,50 @@
+// Copyright 2024 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 (
+
+ 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.
+