From b04b421263278174624b03a44a810a5dd620bb68 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Tue, 4 Aug 2026 01:37:56 +0200 Subject: [PATCH 1/3] feat: collect Entra Domain Services resources --- client/client.go | 1 + client/domain_services.go | 38 +++++ client/mocks/client.go | 14 ++ cmd/list-azure-rm.go | 11 ++ cmd/list-domain-service-role-assignments.go | 135 ++++++++++++++++++ ...st-domain-service-role-assignments_test.go | 85 +++++++++++ cmd/list-domain-services.go | 125 ++++++++++++++++ cmd/list-domain-services_test.go | 74 ++++++++++ enums/kind.go | 2 + models/azure/domain_service.go | 74 ++++++++++ models/domain-service.go | 43 ++++++ models/domain-service_test.go | 75 ++++++++++ 12 files changed, 677 insertions(+) create mode 100644 client/domain_services.go create mode 100644 cmd/list-domain-service-role-assignments.go create mode 100644 cmd/list-domain-service-role-assignments_test.go create mode 100644 cmd/list-domain-services.go create mode 100644 cmd/list-domain-services_test.go create mode 100644 models/azure/domain_service.go create mode 100644 models/domain-service.go create mode 100644 models/domain-service_test.go diff --git a/client/client.go b/client/client.go index 70b57e76..2f4dd5f1 100644 --- a/client/client.go +++ b/client/client.go @@ -229,6 +229,7 @@ type AzureResourceManagerClient interface { ListAzureAutomationAccounts(ctx context.Context, subscriptionId string) <-chan AzureResult[azure.AutomationAccount] ListAzureLogicApps(ctx context.Context, subscriptionId string, filter string, top int32) <-chan AzureResult[azure.LogicApp] ListAzureFunctionApps(ctx context.Context, subscriptionId string) <-chan AzureResult[azure.FunctionApp] + ListAzureDomainServices(ctx context.Context, subscriptionId string) <-chan AzureResult[azure.DomainService] } type AzureClient interface { diff --git a/client/domain_services.go b/client/domain_services.go new file mode 100644 index 00000000..4e0e92e5 --- /dev/null +++ b/client/domain_services.go @@ -0,0 +1,38 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package client + +import ( + "context" + "fmt" + + "github.com/bloodhoundad/azurehound/v2/client/query" + "github.com/bloodhoundad/azurehound/v2/models/azure" +) + +func (s *azureClient) ListAzureDomainServices(ctx context.Context, subscriptionID string) <-chan AzureResult[azure.DomainService] { + var ( + out = make(chan AzureResult[azure.DomainService]) + path = fmt.Sprintf("/subscriptions/%s/providers/Microsoft.AAD/domainServices", subscriptionID) + params = query.RMParams{ApiVersion: "2025-06-01"} + ) + + go getAzureObjectList[azure.DomainService](s.resourceManager, ctx, path, params, out) + + return out +} diff --git a/client/mocks/client.go b/client/mocks/client.go index 5fd9f8b4..8f137659 100644 --- a/client/mocks/client.go +++ b/client/mocks/client.go @@ -324,6 +324,20 @@ func (mr *MockAzureClientMockRecorder) ListAzureDevices(ctx, params any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureDevices", reflect.TypeOf((*MockAzureClient)(nil).ListAzureDevices), ctx, params) } +// ListAzureDomainServices mocks base method. +func (m *MockAzureClient) ListAzureDomainServices(ctx context.Context, subscriptionId string) <-chan client.AzureResult[azure.DomainService] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAzureDomainServices", ctx, subscriptionId) + ret0, _ := ret[0].(<-chan client.AzureResult[azure.DomainService]) + return ret0 +} + +// ListAzureDomainServices indicates an expected call of ListAzureDomainServices. +func (mr *MockAzureClientMockRecorder) ListAzureDomainServices(ctx, subscriptionId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureDomainServices", reflect.TypeOf((*MockAzureClient)(nil).ListAzureDomainServices), ctx, subscriptionId) +} + // ListAzureFunctionApps mocks base method. func (m *MockAzureClient) ListAzureFunctionApps(ctx context.Context, subscriptionId string) <-chan client.AzureResult[azure.FunctionApp] { m.ctrl.T.Helper() diff --git a/cmd/list-azure-rm.go b/cmd/list-azure-rm.go index 68128a0a..cb011642 100644 --- a/cmd/list-azure-rm.go +++ b/cmd/list-azure-rm.go @@ -68,6 +68,9 @@ func listAllRM(ctx context.Context, client client.AzureClient) <-chan interface{ functionApps = make(chan interface{}) functionApps2 = make(chan interface{}) + domainServices = make(chan interface{}) + domainServices2 = make(chan interface{}) + webApps = make(chan interface{}) webApps2 = make(chan interface{}) @@ -119,6 +122,7 @@ func listAllRM(ctx context.Context, client client.AzureClient) <-chan interface{ subscriptions10 = make(chan interface{}) subscriptions11 = make(chan interface{}) subscriptions12 = make(chan interface{}) + subscriptions13 = make(chan interface{}) subscriptionRoleAssignments1 = make(chan interface{}) subscriptionRoleAssignments2 = make(chan interface{}) subscriptionRoleAssignments3 = make(chan interface{}) @@ -147,6 +151,7 @@ func listAllRM(ctx context.Context, client client.AzureClient) <-chan interface{ subscriptions10, subscriptions11, subscriptions12, + subscriptions13, ) pipeline.Tee(ctx.Done(), listResourceGroups(ctx, client, subscriptions2), resourceGroups, resourceGroups2) pipeline.Tee(ctx.Done(), listKeyVaults(ctx, client, subscriptions3), keyVaults, keyVaults2, keyVaults3) @@ -158,6 +163,7 @@ func listAllRM(ctx context.Context, client client.AzureClient) <-chan interface{ pipeline.Tee(ctx.Done(), listLogicApps(ctx, client, subscriptions10), logicApps, logicApps2) pipeline.Tee(ctx.Done(), listManagedClusters(ctx, client, subscriptions11), managedClusters, managedClusters2) pipeline.Tee(ctx.Done(), listVMScaleSets(ctx, client, subscriptions12), vmScaleSets, vmScaleSets2) + pipeline.Tee(ctx.Done(), listDomainServices(ctx, client, subscriptions13), domainServices, domainServices2) // Enumerate Relationships // ManagementGroups: Descendants, Owners, Contributors and UserAccessAdmins @@ -198,6 +204,9 @@ func listAllRM(ctx context.Context, client client.AzureClient) <-chan interface{ // Enumerate Function App Role Assignments functionAppRoleAssignments := listFunctionAppRoleAssignments(ctx, client, functionApps2) + // Enumerate Microsoft Entra Domain Services Role Assignments + domainServiceRoleAssignments := listDomainServiceRoleAssignments(ctx, client, domainServices2) + // Enumerate Web App Role Assignments webAppRoleAssignments := listWebAppRoleAssignments(ctx, client, webApps2) @@ -221,6 +230,8 @@ func listAllRM(ctx context.Context, client client.AzureClient) <-chan interface{ automationAccountRoleAssignments, containerRegistries, containerRegistryRoleAssignments, + domainServices, + domainServiceRoleAssignments, functionApps, functionAppRoleAssignments, keyVaultAccessPolicies, diff --git a/cmd/list-domain-service-role-assignments.go b/cmd/list-domain-service-role-assignments.go new file mode 100644 index 00000000..0fe672b2 --- /dev/null +++ b/cmd/list-domain-service-role-assignments.go @@ -0,0 +1,135 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path" + "strings" + "sync" + "time" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/config" + "github.com/bloodhoundad/azurehound/v2/enums" + "github.com/bloodhoundad/azurehound/v2/models" + "github.com/bloodhoundad/azurehound/v2/panicrecovery" + "github.com/bloodhoundad/azurehound/v2/pipeline" + "github.com/spf13/cobra" +) + +func init() { + listRootCmd.AddCommand(listDomainServiceRoleAssignmentsCmd) +} + +var listDomainServiceRoleAssignmentsCmd = &cobra.Command{ + Use: "domain-service-role-assignments", + Long: "Lists Microsoft Entra Domain Services role assignments", + Run: listDomainServiceRoleAssignmentsCmdImpl, + SilenceUsage: true, +} + +func listDomainServiceRoleAssignmentsCmdImpl(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, os.Kill) + defer gracefulShutdown(stop) + + log.V(1).Info("testing connections") + azClient := connectAndCreateClient() + log.Info("collecting Microsoft Entra Domain Services role assignments...") + start := time.Now() + subscriptions := listSubscriptions(ctx, azClient) + stream := listDomainServiceRoleAssignments(ctx, azClient, listDomainServices(ctx, azClient, subscriptions)) + panicrecovery.HandleBubbledPanic(ctx, stop, log) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listDomainServiceRoleAssignments(ctx context.Context, azureClient client.AzureClient, domainServices <-chan interface{}) <-chan interface{} { + var ( + out = make(chan interface{}) + ids = make(chan string) + streams = pipeline.Demux(ctx.Done(), ids, config.ColStreamCount.Value().(int)) + wg sync.WaitGroup + ) + + go func() { + defer panicrecovery.PanicRecovery() + defer close(ids) + + for result := range pipeline.OrDone(ctx.Done(), domainServices) { + if domainService, ok := result.(AzureWrapper).Data.(models.DomainService); !ok { + log.Error(fmt.Errorf("failed type assertion"), "unable to continue enumerating domain service role assignments", "result", result) + return + } else if ok := pipeline.Send(ctx.Done(), ids, domainService.Id); !ok { + return + } + } + }() + + wg.Add(len(streams)) + for i := range streams { + stream := streams[i] + go func() { + defer panicrecovery.PanicRecovery() + defer wg.Done() + for id := range stream { + var ( + roleAssignments = models.AzureRoleAssignments{ObjectId: id} + count = 0 + ) + for item := range azureClient.ListRoleAssignmentsForResource(ctx, id, "atScope()", "") { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing role assignments for this domain service", "domainServiceId", id) + continue + } + if !strings.EqualFold(item.Ok.Properties.Scope, id) { + continue + } + + roleDefinitionID := path.Base(item.Ok.Properties.RoleDefinitionId) + roleAssignments.RoleAssignments = append(roleAssignments.RoleAssignments, models.AzureRoleAssignment{ + Assignee: item.Ok, + ObjectId: id, + RoleDefinitionId: roleDefinitionID, + }) + log.V(2).Info("found domain service role assignment", "roleDefinitionId", roleDefinitionID) + count++ + } + if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ + Kind: enums.KindAZDomainServiceRoleAssignment, + Data: roleAssignments, + }); !ok { + return + } + log.V(1).Info("finished listing domain service role assignments", "domainServiceId", id, "count", count) + } + }() + } + + go func() { + wg.Wait() + close(out) + log.Info("finished listing all domain service role assignments") + }() + + return out +} diff --git a/cmd/list-domain-service-role-assignments_test.go b/cmd/list-domain-service-role-assignments_test.go new file mode 100644 index 00000000..20d75ec7 --- /dev/null +++ b/cmd/list-domain-service-role-assignments_test.go @@ -0,0 +1,85 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "context" + "testing" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/client/mocks" + "github.com/bloodhoundad/azurehound/v2/constants" + "github.com/bloodhoundad/azurehound/v2/models" + "github.com/bloodhoundad/azurehound/v2/models/azure" + "go.uber.org/mock/gomock" +) + +func TestListDomainServiceRoleAssignments(t *testing.T) { + var ( + ctx = context.Background() + controller = gomock.NewController(t) + mockClient = mocks.NewMockAzureClient(controller) + domainServices = make(chan interface{}) + roleAssignmentResults = make(chan client.AzureResult[azure.RoleAssignment]) + resourceID = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.AAD/domainServices/example.com" + ) + + mockClient.EXPECT().ListRoleAssignmentsForResource(gomock.Any(), resourceID, "atScope()", "").Return(roleAssignmentResults) + results := listDomainServiceRoleAssignments(ctx, mockClient, domainServices) + + go func() { + defer close(domainServices) + domainServices <- AzureWrapper{Data: models.DomainService{DomainService: azure.DomainService{Entity: azure.Entity{Id: resourceID}}}} + }() + go func() { + defer close(roleAssignmentResults) + roleAssignmentResults <- client.AzureResult[azure.RoleAssignment]{Ok: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "inherited-principal", + RoleDefinitionId: "/providers/Microsoft.Authorization/roleDefinitions/" + constants.OwnerRoleID, + Scope: "/subscriptions/sub/resourceGroups/rg", + }, + }} + roleAssignmentResults <- client.AzureResult[azure.RoleAssignment]{Ok: azure.RoleAssignment{ + Properties: azure.RoleAssignmentPropertiesWithScope{ + PrincipalId: "principal", + RoleDefinitionId: "/providers/Microsoft.Authorization/roleDefinitions/" + constants.DomainServicesContributorRoleID, + Scope: resourceID, + }, + }} + }() + + result, ok := <-results + if !ok { + t.Fatal("failed to receive role assignments") + } + wrapper, ok := result.(AzureWrapper) + if !ok { + t.Fatalf("failed type assertion: got %T, want %T", result, AzureWrapper{}) + } + roleAssignments, ok := wrapper.Data.(models.AzureRoleAssignments) + if !ok { + t.Fatalf("failed type assertion: got %T, want %T", wrapper.Data, models.AzureRoleAssignments{}) + } + if len(roleAssignments.RoleAssignments) != 1 { + t.Fatalf("expected one role assignment, got %d", len(roleAssignments.RoleAssignments)) + } + if roleAssignments.RoleAssignments[0].RoleDefinitionId != constants.DomainServicesContributorRoleID { + t.Errorf("unexpected role definition id: %s", roleAssignments.RoleAssignments[0].RoleDefinitionId) + } +} diff --git a/cmd/list-domain-services.go b/cmd/list-domain-services.go new file mode 100644 index 00000000..143cd6d4 --- /dev/null +++ b/cmd/list-domain-services.go @@ -0,0 +1,125 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "time" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/config" + "github.com/bloodhoundad/azurehound/v2/enums" + "github.com/bloodhoundad/azurehound/v2/models" + "github.com/bloodhoundad/azurehound/v2/panicrecovery" + "github.com/bloodhoundad/azurehound/v2/pipeline" + "github.com/spf13/cobra" +) + +func init() { + listRootCmd.AddCommand(listDomainServicesCmd) +} + +var listDomainServicesCmd = &cobra.Command{ + Use: "domain-services", + Long: "Lists Microsoft Entra Domain Services resources", + Run: listDomainServicesCmdImpl, + SilenceUsage: true, +} + +func listDomainServicesCmdImpl(cmd *cobra.Command, args []string) { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, os.Kill) + defer gracefulShutdown(stop) + + log.V(1).Info("testing connections") + azClient := connectAndCreateClient() + log.Info("collecting Microsoft Entra Domain Services resources...") + start := time.Now() + stream := listDomainServices(ctx, azClient, listSubscriptions(ctx, azClient)) + panicrecovery.HandleBubbledPanic(ctx, stop, log) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listDomainServices(ctx context.Context, azureClient client.AzureClient, subscriptions <-chan interface{}) <-chan interface{} { + var ( + out = make(chan interface{}) + ids = make(chan string) + streams = pipeline.Demux(ctx.Done(), ids, config.ColStreamCount.Value().(int)) + wg sync.WaitGroup + ) + + go func() { + defer panicrecovery.PanicRecovery() + defer close(ids) + for result := range pipeline.OrDone(ctx.Done(), subscriptions) { + if subscription, ok := result.(AzureWrapper).Data.(models.Subscription); !ok { + log.Error(fmt.Errorf("failed type assertion"), "unable to continue enumerating domain services", "result", result) + return + } else if ok := pipeline.Send(ctx.Done(), ids, subscription.SubscriptionId); !ok { + return + } + } + }() + + wg.Add(len(streams)) + for i := range streams { + stream := streams[i] + go func() { + defer panicrecovery.PanicRecovery() + defer wg.Done() + for subscriptionID := range stream { + count := 0 + for item := range azureClient.ListAzureDomainServices(ctx, subscriptionID) { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing domain services for this subscription", "subscriptionId", subscriptionID) + continue + } + + domainService := models.DomainService{ + DomainService: item.Ok, + SubscriptionID: "/subscriptions/" + subscriptionID, + ResourceGroupID: item.Ok.ResourceGroupID(), + ResourceGroupName: item.Ok.ResourceGroupName(), + } + log.V(2).Info("found domain service", "name", domainService.Name) + count++ + if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ + Kind: enums.KindAZDomainService, + Data: domainService, + }); !ok { + return + } + } + log.V(1).Info("finished listing domain services", "subscriptionId", subscriptionID, "count", count) + } + }() + } + + go func() { + wg.Wait() + close(out) + log.Info("finished listing all domain services") + }() + + return out +} diff --git a/cmd/list-domain-services_test.go b/cmd/list-domain-services_test.go new file mode 100644 index 00000000..a0540b70 --- /dev/null +++ b/cmd/list-domain-services_test.go @@ -0,0 +1,74 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "context" + "testing" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/client/mocks" + "github.com/bloodhoundad/azurehound/v2/models" + "github.com/bloodhoundad/azurehound/v2/models/azure" + "go.uber.org/mock/gomock" +) + +func TestListDomainServices(t *testing.T) { + var ( + ctx = context.Background() + controller = gomock.NewController(t) + mockClient = mocks.NewMockAzureClient(controller) + subscriptions = make(chan interface{}) + domainServiceResults = make(chan client.AzureResult[azure.DomainService]) + resourceID = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.AAD/domainServices/example.com" + ) + + mockClient.EXPECT().ListAzureDomainServices(gomock.Any(), "sub").Return(domainServiceResults) + results := listDomainServices(ctx, mockClient, subscriptions) + + go func() { + defer close(subscriptions) + subscriptions <- AzureWrapper{Data: models.Subscription{Subscription: azure.Subscription{SubscriptionId: "sub"}}} + }() + go func() { + defer close(domainServiceResults) + domainServiceResults <- client.AzureResult[azure.DomainService]{Ok: azure.DomainService{ + Entity: azure.Entity{Id: resourceID}, + Name: "example.com", + }} + }() + + result, ok := <-results + if !ok { + t.Fatal("failed to receive domain service") + } + wrapper, ok := result.(AzureWrapper) + if !ok { + t.Fatalf("failed type assertion: got %T, want %T", result, AzureWrapper{}) + } + domainService, ok := wrapper.Data.(models.DomainService) + if !ok { + t.Fatalf("failed type assertion: got %T, want %T", wrapper.Data, models.DomainService{}) + } + if domainService.ResourceGroupID != "/subscriptions/sub/resourceGroups/rg" { + t.Errorf("unexpected resource group id: %s", domainService.ResourceGroupID) + } + if _, ok := <-results; ok { + t.Error("expected domain service stream to close") + } +} diff --git a/enums/kind.go b/enums/kind.go index 7acb6288..512f4ee8 100644 --- a/enums/kind.go +++ b/enums/kind.go @@ -75,6 +75,8 @@ const ( KindAZLogicAppRoleAssignment Kind = "AZLogicAppRoleAssignment" KindAZFunctionApp Kind = "AZFunctionApp" KindAZFunctionAppRoleAssignment Kind = "AZFunctionAppRoleAssignment" + KindAZDomainService Kind = "AZDomainService" + KindAZDomainServiceRoleAssignment Kind = "AZDomainServiceRoleAssignment" KindAZContainerRegistry Kind = "AZContainerRegistry" KindAZContainerRegistryRoleAssignment Kind = "AZContainerRegistryRoleAssignment" KindAZWebApp Kind = "AZWebApp" diff --git a/models/azure/domain_service.go b/models/azure/domain_service.go new file mode 100644 index 00000000..6f37c390 --- /dev/null +++ b/models/azure/domain_service.go @@ -0,0 +1,74 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package azure + +import "strings" + +type DomainServiceLDAPSSettings struct { + LDAPS string `json:"ldaps,omitempty"` + ExternalAccess string `json:"externalAccess,omitempty"` +} + +type DomainServiceSecuritySettings struct { + NTLMV1 string `json:"ntlmV1,omitempty"` + TLSV1 string `json:"tlsV1,omitempty"` + SyncNTLMPasswords string `json:"syncNtlmPasswords,omitempty"` + SyncKerberosPasswords string `json:"syncKerberosPasswords,omitempty"` + SyncOnPremPasswords string `json:"syncOnPremPasswords,omitempty"` + KerberosRC4Encryption string `json:"kerberosRc4Encryption,omitempty"` + KerberosArmoring string `json:"kerberosArmoring,omitempty"` + LDAPSigning string `json:"ldapSigning,omitempty"` + ChannelBinding string `json:"channelBinding,omitempty"` + SyncOnPremSAMAccountName string `json:"syncOnPremSamAccountName,omitempty"` +} + +type DomainServiceProperties struct { + TenantID string `json:"tenantId,omitempty"` + DomainName string `json:"domainName,omitempty"` + DomainConfigurationType string `json:"domainConfigurationType,omitempty"` + FilteredSync string `json:"filteredSync,omitempty"` + SyncScope string `json:"syncScope,omitempty"` + SyncApplicationID string `json:"syncApplicationId,omitempty"` + DomainSecuritySettings DomainServiceSecuritySettings `json:"domainSecuritySettings,omitempty"` + LDAPSSettings DomainServiceLDAPSSettings `json:"ldapsSettings,omitempty"` +} + +type DomainService struct { + Entity + + Location string `json:"location,omitempty"` + Name string `json:"name,omitempty"` + Properties DomainServiceProperties `json:"properties,omitempty"` + Type string `json:"type,omitempty"` +} + +func (s DomainService) ResourceGroupName() string { + parts := strings.Split(s.Id, "/") + if len(parts) > 4 { + return parts[4] + } + return "" +} + +func (s DomainService) ResourceGroupID() string { + parts := strings.Split(s.Id, "/") + if len(parts) > 5 { + return strings.Join(parts[:5], "/") + } + return "" +} diff --git a/models/domain-service.go b/models/domain-service.go new file mode 100644 index 00000000..812e41c7 --- /dev/null +++ b/models/domain-service.go @@ -0,0 +1,43 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package models + +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) + +type DomainService struct { + azure.DomainService + SubscriptionID string `json:"subscriptionId"` + ResourceGroupID string `json:"resourceGroupId"` + ResourceGroupName string `json:"resourceGroupName"` +} + +func (s DomainService) MarshalJSON() ([]byte, error) { + type Alias DomainService + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.SubscriptionID = strings.ToUpper(a.SubscriptionID) + a.ResourceGroupID = strings.ToUpper(a.ResourceGroupID) + a.Properties.TenantID = strings.ToUpper(a.Properties.TenantID) + a.Properties.SyncApplicationID = strings.ToUpper(a.Properties.SyncApplicationID) + return json.Marshal(a) +} diff --git a/models/domain-service_test.go b/models/domain-service_test.go new file mode 100644 index 00000000..a8c7b6e0 --- /dev/null +++ b/models/domain-service_test.go @@ -0,0 +1,75 @@ +// Copyright (C) 2022 Specter Ops, Inc. +// +// This file is part of AzureHound. +// +// AzureHound is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AzureHound is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package models + +import ( + "encoding/json" + "testing" +) + +func TestDomainServiceMarshalJSON(t *testing.T) { + var domainService DomainService + raw := []byte(`{ + "id":"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.AAD/domainServices/example.com", + "name":"example.com", + "properties":{ + "tenantId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "domainName":"example.com", + "syncApplicationId":"11111111-2222-3333-4444-555555555555", + "ldapsSettings":{ + "ldaps":"Enabled", + "externalAccess":"Disabled", + "publicCertificate":"certificate-data", + "certificateNotAfter":"2030-01-01T00:00:00Z", + "certificateThumbprint":"thumbprint" + } + } + }`) + if err := json.Unmarshal(raw, &domainService); err != nil { + t.Fatalf("failed to unmarshal domain service: %v", err) + } + + domainService.SubscriptionID = "/subscriptions/sub" + domainService.ResourceGroupID = "/subscriptions/sub/resourceGroups/rg" + encoded, err := json.Marshal(domainService) + if err != nil { + t.Fatalf("failed to marshal domain service: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(encoded, &result); err != nil { + t.Fatalf("failed to inspect marshaled domain service: %v", err) + } + properties := result["properties"].(map[string]any) + ldapsSettings := properties["ldapsSettings"].(map[string]any) + + if result["id"] != "/SUBSCRIPTIONS/SUB/RESOURCEGROUPS/RG/PROVIDERS/MICROSOFT.AAD/DOMAINSERVICES/EXAMPLE.COM" { + t.Errorf("expected uppercased resource id, got %v", result["id"]) + } + if properties["tenantId"] != "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE" { + t.Errorf("expected uppercased tenant id, got %v", properties["tenantId"]) + } + if properties["syncApplicationId"] != "11111111-2222-3333-4444-555555555555" { + t.Errorf("expected sync application id to be retained, got %v", properties["syncApplicationId"]) + } + for _, excluded := range []string{"publicCertificate", "certificateNotAfter", "certificateThumbprint"} { + if _, found := ldapsSettings[excluded]; found { + t.Errorf("did not expect %s in marshaled LDAPS settings", excluded) + } + } +} From 2ef9957339cdc35c229643d5b8e531cf9c7706dd Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Wed, 5 Aug 2026 22:43:55 +0200 Subject: [PATCH 2/3] Rename DomainService nodes-edges to EntraDS --- cmd/list-domain-service-role-assignments.go | 2 +- cmd/list-domain-services.go | 2 +- enums/kind.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/list-domain-service-role-assignments.go b/cmd/list-domain-service-role-assignments.go index 0fe672b2..7123c973 100644 --- a/cmd/list-domain-service-role-assignments.go +++ b/cmd/list-domain-service-role-assignments.go @@ -115,7 +115,7 @@ func listDomainServiceRoleAssignments(ctx context.Context, azureClient client.Az count++ } if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ - Kind: enums.KindAZDomainServiceRoleAssignment, + Kind: enums.KindAZEntraDSRoleAssignment, Data: roleAssignments, }); !ok { return diff --git a/cmd/list-domain-services.go b/cmd/list-domain-services.go index 143cd6d4..55d9c95b 100644 --- a/cmd/list-domain-services.go +++ b/cmd/list-domain-services.go @@ -104,7 +104,7 @@ func listDomainServices(ctx context.Context, azureClient client.AzureClient, sub log.V(2).Info("found domain service", "name", domainService.Name) count++ if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ - Kind: enums.KindAZDomainService, + Kind: enums.KindAZEntraDS, Data: domainService, }); !ok { return diff --git a/enums/kind.go b/enums/kind.go index 512f4ee8..d686a2db 100644 --- a/enums/kind.go +++ b/enums/kind.go @@ -75,8 +75,8 @@ const ( KindAZLogicAppRoleAssignment Kind = "AZLogicAppRoleAssignment" KindAZFunctionApp Kind = "AZFunctionApp" KindAZFunctionAppRoleAssignment Kind = "AZFunctionAppRoleAssignment" - KindAZDomainService Kind = "AZDomainService" - KindAZDomainServiceRoleAssignment Kind = "AZDomainServiceRoleAssignment" + KindAZEntraDS Kind = "AZEntraDS" + KindAZEntraDSRoleAssignment Kind = "AZEntraDSRoleAssignment" KindAZContainerRegistry Kind = "AZContainerRegistry" KindAZContainerRegistryRoleAssignment Kind = "AZContainerRegistryRoleAssignment" KindAZWebApp Kind = "AZWebApp" From 92b1ed2a3d956ec77699b31b7d93005a6cc788c6 Mon Sep 17 00:00:00 2001 From: Martin Sohn Christensen Date: Mon, 10 Aug 2026 14:57:52 +0200 Subject: [PATCH 3/3] test: assert Entra DS collection kinds --- cmd/list-domain-service-role-assignments_test.go | 4 ++++ cmd/list-domain-services_test.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/cmd/list-domain-service-role-assignments_test.go b/cmd/list-domain-service-role-assignments_test.go index 20d75ec7..fdcbeafa 100644 --- a/cmd/list-domain-service-role-assignments_test.go +++ b/cmd/list-domain-service-role-assignments_test.go @@ -24,6 +24,7 @@ import ( "github.com/bloodhoundad/azurehound/v2/client" "github.com/bloodhoundad/azurehound/v2/client/mocks" "github.com/bloodhoundad/azurehound/v2/constants" + "github.com/bloodhoundad/azurehound/v2/enums" "github.com/bloodhoundad/azurehound/v2/models" "github.com/bloodhoundad/azurehound/v2/models/azure" "go.uber.org/mock/gomock" @@ -72,6 +73,9 @@ func TestListDomainServiceRoleAssignments(t *testing.T) { if !ok { t.Fatalf("failed type assertion: got %T, want %T", result, AzureWrapper{}) } + if wrapper.Kind != enums.KindAZEntraDSRoleAssignment { + t.Errorf("unexpected kind: %s", wrapper.Kind) + } roleAssignments, ok := wrapper.Data.(models.AzureRoleAssignments) if !ok { t.Fatalf("failed type assertion: got %T, want %T", wrapper.Data, models.AzureRoleAssignments{}) diff --git a/cmd/list-domain-services_test.go b/cmd/list-domain-services_test.go index a0540b70..eba07e9f 100644 --- a/cmd/list-domain-services_test.go +++ b/cmd/list-domain-services_test.go @@ -23,6 +23,7 @@ import ( "github.com/bloodhoundad/azurehound/v2/client" "github.com/bloodhoundad/azurehound/v2/client/mocks" + "github.com/bloodhoundad/azurehound/v2/enums" "github.com/bloodhoundad/azurehound/v2/models" "github.com/bloodhoundad/azurehound/v2/models/azure" "go.uber.org/mock/gomock" @@ -61,6 +62,9 @@ func TestListDomainServices(t *testing.T) { if !ok { t.Fatalf("failed type assertion: got %T, want %T", result, AzureWrapper{}) } + if wrapper.Kind != enums.KindAZEntraDS { + t.Errorf("unexpected kind: %s", wrapper.Kind) + } domainService, ok := wrapper.Data.(models.DomainService) if !ok { t.Fatalf("failed type assertion: got %T, want %T", wrapper.Data, models.DomainService{})