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..7123c973
--- /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.KindAZEntraDSRoleAssignment,
+ 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..fdcbeafa
--- /dev/null
+++ b/cmd/list-domain-service-role-assignments_test.go
@@ -0,0 +1,89 @@
+// 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/enums"
+ "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{})
+ }
+ 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{})
+ }
+ 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..55d9c95b
--- /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.KindAZEntraDS,
+ 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..eba07e9f
--- /dev/null
+++ b/cmd/list-domain-services_test.go
@@ -0,0 +1,78 @@
+// 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/enums"
+ "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{})
+ }
+ 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{})
+ }
+ 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..d686a2db 100644
--- a/enums/kind.go
+++ b/enums/kind.go
@@ -75,6 +75,8 @@ const (
KindAZLogicAppRoleAssignment Kind = "AZLogicAppRoleAssignment"
KindAZFunctionApp Kind = "AZFunctionApp"
KindAZFunctionAppRoleAssignment Kind = "AZFunctionAppRoleAssignment"
+ KindAZEntraDS Kind = "AZEntraDS"
+ KindAZEntraDSRoleAssignment Kind = "AZEntraDSRoleAssignment"
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)
+ }
+ }
+}