diff --git a/client/client.go b/client/client.go index 47a777bd..50526a7c 100644 --- a/client/client.go +++ b/client/client.go @@ -180,6 +180,9 @@ type AzureGraphClient interface { ListAzureADGroups(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Group] ListAzureADGroupMembers(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] ListAzureADGroupOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] + ListAzureADGroups365(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Group365] + ListAzureADGroup365Members(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] + ListAzureADGroup365Owners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] ListAzureADAppOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] ListAzureADApps(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Application] ListAzureADUsers(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.User] diff --git a/client/groups365.go b/client/groups365.go new file mode 100644 index 00000000..492dc79c --- /dev/null +++ b/client/groups365.go @@ -0,0 +1,72 @@ +// 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" + "encoding/json" + "fmt" + + "github.com/bloodhoundad/azurehound/v2/client/query" + "github.com/bloodhoundad/azurehound/v2/constants" + "github.com/bloodhoundad/azurehound/v2/models/azure" +) + +// ListAzureGroups Microsoft 365 https://learn.microsoft.com/en-us/graph/api/group-list?view=graph-rest-beta +func (s *azureClient) ListAzureADGroups365(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Group365] { + var ( + out = make(chan AzureResult[azure.Group365]) + path = fmt.Sprintf("/%s/groups", constants.GraphApiVersion) + ) + + if params.Top == 0 { + params.Top = 99 + } + + go getAzureObjectList[azure.Group365](s.msgraph, ctx, path, params, out) + + return out +} + +// ListAzureADGroupOwners Microsoft 365 https://learn.microsoft.com/en-us/graph/api/group-list-owners?view=graph-rest-beta +func (s *azureClient) ListAzureADGroup365Owners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] { + var ( + out = make(chan AzureResult[json.RawMessage]) + path = fmt.Sprintf("/%s/groups/%s/owners", constants.GraphApiBetaVersion, objectId) + ) + + if params.Top == 0 { + params.Top = 99 + } + + go getAzureObjectList[json.RawMessage](s.msgraph, ctx, path, params, out) + + return out +} + +// ListAzureADGroupMembers Microsoft 365 https://learn.microsoft.com/en-us/graph/api/group-list-members?view=graph-rest-beta +func (s *azureClient) ListAzureADGroup365Members(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] { + var ( + out = make(chan AzureResult[json.RawMessage]) + path = fmt.Sprintf("/%s/groups/%s/members", constants.GraphApiBetaVersion, objectId) + ) + + go getAzureObjectList[json.RawMessage](s.msgraph, ctx, path, params, out) + + return out +} diff --git a/client/mocks/client.go b/client/mocks/client.go index 8ae588bc..b24ef556 100644 --- a/client/mocks/client.go +++ b/client/mocks/client.go @@ -128,6 +128,34 @@ func (mr *MockAzureClientMockRecorder) ListAzureADApps(ctx, params any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureADApps", reflect.TypeOf((*MockAzureClient)(nil).ListAzureADApps), ctx, params) } +// ListAzureADGroup365Members mocks base method. +func (m *MockAzureClient) ListAzureADGroup365Members(arg0 context.Context, arg1 string, arg2 query.GraphParams) <-chan client.AzureResult[json.RawMessage] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAzureADGroup365Members", arg0, arg1, arg2) + ret0, _ := ret[0].(<-chan client.AzureResult[json.RawMessage]) + return ret0 +} + +// ListAzureADGroup365Members indicates an expected call of ListAzureADGroup365Members. +func (mr *MockAzureClientMockRecorder) ListAzureADGroup365Members(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureADGroup365Members", reflect.TypeOf((*MockAzureClient)(nil).ListAzureADGroup365Members), arg0, arg1, arg2) +} + +// ListAzureADGroup365Owners mocks base method. +func (m *MockAzureClient) ListAzureADGroup365Owners(arg0 context.Context, arg1 string, arg2 query.GraphParams) <-chan client.AzureResult[json.RawMessage] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAzureADGroup365Owners", arg0, arg1, arg2) + ret0, _ := ret[0].(<-chan client.AzureResult[json.RawMessage]) + return ret0 +} + +// ListAzureADGroup365Owners indicates an expected call of ListAzureADGroup365Owners. +func (mr *MockAzureClientMockRecorder) ListAzureADGroup365Owners(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureADGroup365Owners", reflect.TypeOf((*MockAzureClient)(nil).ListAzureADGroup365Owners), arg0, arg1, arg2) +} + // ListAzureADGroupMembers mocks base method. func (m *MockAzureClient) ListAzureADGroupMembers(ctx context.Context, objectId string, params query.GraphParams) <-chan client.AzureResult[json.RawMessage] { m.ctrl.T.Helper() @@ -170,6 +198,20 @@ func (mr *MockAzureClientMockRecorder) ListAzureADGroups(ctx, params any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureADGroups", reflect.TypeOf((*MockAzureClient)(nil).ListAzureADGroups), ctx, params) } +// ListAzureADGroups365 mocks base method. +func (m *MockAzureClient) ListAzureADGroups365(arg0 context.Context, arg1 query.GraphParams) <-chan client.AzureResult[azure.Group365] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAzureADGroups365", arg0, arg1) + ret0, _ := ret[0].(<-chan client.AzureResult[azure.Group365]) + return ret0 +} + +// ListAzureADGroups365 indicates an expected call of ListAzureADGroups365. +func (mr *MockAzureClientMockRecorder) ListAzureADGroups365(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureADGroups365", reflect.TypeOf((*MockAzureClient)(nil).ListAzureADGroups365), arg0, arg1) +} + // ListAzureADRoleAssignments mocks base method. func (m *MockAzureClient) ListAzureADRoleAssignments(ctx context.Context, params query.GraphParams) <-chan client.AzureResult[azure.UnifiedRoleAssignment] { m.ctrl.T.Helper() @@ -546,4 +588,4 @@ func (m *MockAzureClient) TenantInfo() azure.Tenant { func (mr *MockAzureClientMockRecorder) TenantInfo() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TenantInfo", reflect.TypeOf((*MockAzureClient)(nil).TenantInfo)) -} +} \ No newline at end of file diff --git a/cmd/list-azure-ad.go b/cmd/list-azure-ad.go index f3307cf7..9e98e4ba 100644 --- a/cmd/list-azure-ad.go +++ b/cmd/list-azure-ad.go @@ -70,6 +70,10 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{ groups2 = make(chan interface{}) groups3 = make(chan interface{}) + o365groups = make(chan interface{}) + o365groups2 = make(chan interface{}) + o365groups3 = make(chan interface{}) + roles = make(chan interface{}) roles2 = make(chan interface{}) @@ -94,6 +98,11 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{ groupOwners := listGroupOwners(ctx, client, groups2) groupMembers := listGroupMembers(ctx, client, groups3) + // Enumerate Microsoft 365 Groups, GroupOwners and GroupMembers + pipeline.Tee(ctx.Done(), listGroups365(ctx, client), o365groups, o365groups2, o365groups3) + group365Owners := listGroup365Owners(ctx, client, o365groups2) + group365Members := listGroup365Members(ctx, client, o365groups3) + // Enumerate ServicePrincipals and ServicePrincipalOwners pipeline.Tee(ctx.Done(), listServicePrincipals(ctx, client), servicePrincipals, servicePrincipals2, servicePrincipals3) servicePrincipalOwners := listServicePrincipalOwners(ctx, client, servicePrincipals2) @@ -126,6 +135,9 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{ groupMembers, groupOwners, groups, + group365Members, + group365Owners, + o365groups, roleAssignments, roles, servicePrincipalOwners, diff --git a/cmd/list-group-o365-members.go b/cmd/list-group-o365-members.go new file mode 100644 index 00000000..aa7e7fae --- /dev/null +++ b/cmd/list-group-o365-members.go @@ -0,0 +1,142 @@ +// 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/client/query" + "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(listGroup365MembersCmd) + listGroup365MembersCmd.Flags().StringSliceVar(&listGroup365MembersSelect, "select", []string{"id,displayName,createdDateTime"}, `Select properties to include. Use "" for Azure default properties. Azurehound default is "id,displayName,createdDateTime" if flag is not supplied.`) +} + +var listGroup365MembersCmd = &cobra.Command{ + Use: "group365-members", + Long: "Lists Azure AD Group Microsoft 365 Members", + Run: listGroup365MembersCmdImpl, + SilenceUsage: true, +} + +var listGroup365MembersSelect []string + +func listGroup365MembersCmdImpl(cmd *cobra.Command, _ []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 azure group microsoft 365 members...") + start := time.Now() + stream := listGroup365Members(ctx, azClient, listGroups365(ctx, azClient)) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listGroup365Members(ctx context.Context, client client.AzureClient, groups <-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 + params = query.GraphParams{ + Select: unique(listGroup365MembersSelect), + Filter: "", + Count: false, + Search: "", + Top: 0, + Expand: "", + } + ) + + go func() { + defer panicrecovery.PanicRecovery() + defer close(ids) + + for result := range pipeline.OrDone(ctx.Done(), groups) { + if group, ok := result.(AzureWrapper).Data.(models.Group365); !ok { + log.Error(fmt.Errorf("failed group 365 type assertion"), "unable to continue enumerating group Microsoft 365 members", "result", result) + return + } else { + if ok := pipeline.Send(ctx.Done(), ids, group.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 ( + data = models.Group365Members{ + GroupId: id, + } + count = 0 + ) + for item := range client.ListAzureADGroup365Members(ctx, id, params) { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing members for this Microsoft 365 group", "groupId", id) + } else { + group365Member := models.Group365Member{ + Member: item.Ok, + GroupId: id, + } + log.V(2).Info("found group Microsoft 365 member", "groupMember", group365Member) + count++ + data.Members = append(data.Members, group365Member) + } + } + if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ + Kind: enums.KindAZGroup365Member, + Data: data, + }); !ok { + return + } + log.V(1).Info("finished listing group memberships", "groupId", id, "count", count) + } + }() + } + + go func() { + wg.Wait() + close(out) + log.Info("finished listing members for all Microsoft 365 groups") + }() + + return out +} diff --git a/cmd/list-group-o365-members_test.go b/cmd/list-group-o365-members_test.go new file mode 100644 index 00000000..b22e873c --- /dev/null +++ b/cmd/list-group-o365-members_test.go @@ -0,0 +1,102 @@ +// 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" + "encoding/json" + "fmt" + "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 init() { + setupLogger() +} + +func TestListGroup365Members(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := context.Background() + + mockClient := mocks.NewMockAzureClient(ctrl) + + mockGroups365Channel := make(chan interface{}) + mockGroup365MemberChannel := make(chan client.AzureResult[json.RawMessage]) + mockGroup365MemberChannel2 := make(chan client.AzureResult[json.RawMessage]) + + mockTenant := azure.Tenant{} + mockError := fmt.Errorf("I'm an error") + mockClient.EXPECT().TenantInfo().Return(mockTenant).AnyTimes() + mockClient.EXPECT().ListAzureADGroup365Members(gomock.Any(), gomock.Any(), gomock.Any()).Return(mockGroup365MemberChannel).Times(1) + mockClient.EXPECT().ListAzureADGroup365Members(gomock.Any(), gomock.Any(), gomock.Any()).Return(mockGroup365MemberChannel2).Times(1) + channel := listGroup365Members(ctx, mockClient, mockGroups365Channel) + + go func() { + defer close(mockGroups365Channel) + mockGroups365Channel <- AzureWrapper{ + Data: models.Group365{}, + } + mockGroups365Channel <- AzureWrapper{ + Data: models.Group365{}, + } + }() + go func() { + defer close(mockGroup365MemberChannel) + mockGroup365MemberChannel <- client.AzureResult[json.RawMessage]{ + Ok: json.RawMessage{}, + } + mockGroup365MemberChannel <- client.AzureResult[json.RawMessage]{ + Ok: json.RawMessage{}, + } + }() + go func() { + defer close(mockGroup365MemberChannel2) + mockGroup365MemberChannel2 <- client.AzureResult[json.RawMessage]{ + Ok: json.RawMessage{}, + } + mockGroup365MemberChannel2 <- client.AzureResult[json.RawMessage]{ + Error: mockError, + } + }() + + if result, ok := <-channel; !ok { + t.Fatalf("failed to receive from channel") + } else if wrapper, ok := result.(AzureWrapper); !ok { + t.Errorf("failed type assertion: got %T, want %T", result, AzureWrapper{}) + } else if data, ok := wrapper.Data.(models.Group365Members); !ok { + t.Errorf("failed type assertion: got %T, want %T", wrapper.Data, models.Group365Members{}) + } else if len(data.Members) != 2 { + t.Errorf("got %v, want %v", len(data.Members), 2) + } + + if result, ok := <-channel; !ok { + t.Fatalf("failed to receive from channel") + } else if wrapper, ok := result.(AzureWrapper); !ok { + t.Errorf("failed type assertion: got %T, want %T", result, AzureWrapper{}) + } else if data, ok := wrapper.Data.(models.Group365Members); !ok { + t.Errorf("failed type assertion: got %T, want %T", wrapper.Data, models.Group365Members{}) + } else if len(data.Members) != 1 { + t.Errorf("got %v, want %v", len(data.Members), 1) + } +} diff --git a/cmd/list-group-o365-owners.go b/cmd/list-group-o365-owners.go new file mode 100644 index 00000000..c93ef5fa --- /dev/null +++ b/cmd/list-group-o365-owners.go @@ -0,0 +1,132 @@ +// 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/client/query" + "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(listGroup365OwnersCmd) +} + +var listGroup365OwnersCmd = &cobra.Command{ + Use: "group365-owners", + Long: "Lists Azure AD Group Owners", + Run: listGroup365OwnersCmdImpl, + SilenceUsage: true, +} + +func listGroup365OwnersCmdImpl(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 azure group owners...") + start := time.Now() + stream := listGroup365Owners(ctx, azClient, listGroups365(ctx, azClient)) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listGroup365Owners(ctx context.Context, client client.AzureClient, groups <-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 + params = query.GraphParams{} + ) + + go func() { + defer panicrecovery.PanicRecovery() + defer close(ids) + + for result := range pipeline.OrDone(ctx.Done(), groups) { + if group, ok := result.(AzureWrapper).Data.(models.Group365); !ok { + log.Error(fmt.Errorf("failed type assertion"), "unable to continue enumerating group owners", "result", result) + return + } else { + if ok := pipeline.Send(ctx.Done(), ids, group.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 ( + groupOwners = models.Group365Owners{ + GroupId: id, + } + count = 0 + ) + for item := range client.ListAzureADGroup365Owners(ctx, id, params) { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing owners for this Microsoft 365 group", "groupId", id) + } else { + groupOwner := models.Group365Owner{ + Owner: item.Ok, + GroupId: id, + } + log.V(2).Info("found Microsoft 365 group owner", "groupOwner", groupOwner) + count++ + groupOwners.Owners = append(groupOwners.Owners, groupOwner) + } + } + if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ + Kind: enums.KindAZGroup365Owner, + Data: groupOwners, + }); !ok { + return + } + log.V(1).Info("finished listing Microsoft 365 group owners", "groupId", id, "count", count) + } + }() + } + + go func() { + wg.Wait() + close(out) + log.Info("finished listing all Microsoft 365 group owners") + }() + + return out +} diff --git a/cmd/list-group-o365-owners_test.go b/cmd/list-group-o365-owners_test.go new file mode 100644 index 00000000..0c8b13c3 --- /dev/null +++ b/cmd/list-group-o365-owners_test.go @@ -0,0 +1,102 @@ +// 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" + "encoding/json" + "fmt" + "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 init() { + setupLogger() +} + +func TestListGroup365Owners(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := context.Background() + + mockClient := mocks.NewMockAzureClient(ctrl) + + mockGroups365Channel := make(chan interface{}) + mockGroup365OwnerChannel := make(chan client.AzureResult[json.RawMessage]) + mockGroup365OwnerChannel2 := make(chan client.AzureResult[json.RawMessage]) + + mockTenant := azure.Tenant{} + mockError := fmt.Errorf("I'm an error") + mockClient.EXPECT().TenantInfo().Return(mockTenant).AnyTimes() + mockClient.EXPECT().ListAzureADGroup365Owners(gomock.Any(), gomock.Any(), gomock.Any()).Return(mockGroup365OwnerChannel).Times(1) + mockClient.EXPECT().ListAzureADGroup365Owners(gomock.Any(), gomock.Any(), gomock.Any()).Return(mockGroup365OwnerChannel2).Times(1) + channel := listGroup365Owners(ctx, mockClient, mockGroups365Channel) + + go func() { + defer close(mockGroups365Channel) + mockGroups365Channel <- AzureWrapper{ + Data: models.Group365{}, + } + mockGroups365Channel <- AzureWrapper{ + Data: models.Group365{}, + } + }() + go func() { + defer close(mockGroup365OwnerChannel) + mockGroup365OwnerChannel <- client.AzureResult[json.RawMessage]{ + Ok: json.RawMessage{}, + } + mockGroup365OwnerChannel <- client.AzureResult[json.RawMessage]{ + Ok: json.RawMessage{}, + } + }() + go func() { + defer close(mockGroup365OwnerChannel2) + mockGroup365OwnerChannel2 <- client.AzureResult[json.RawMessage]{ + Ok: json.RawMessage{}, + } + mockGroup365OwnerChannel2 <- client.AzureResult[json.RawMessage]{ + Error: mockError, + } + }() + + if result, ok := <-channel; !ok { + t.Fatalf("failed to receive from channel") + } else if wrapper, ok := result.(AzureWrapper); !ok { + t.Errorf("failed type assertion: got %T, want %T", result, AzureWrapper{}) + } else if data, ok := wrapper.Data.(models.Group365Owners); !ok { + t.Errorf("failed type assertion: got %T, want %T", wrapper.Data, models.Group365Owners{}) + } else if len(data.Owners) != 2 { + t.Errorf("got %v, want %v", len(data.Owners), 2) + } + + if result, ok := <-channel; !ok { + t.Fatalf("failed to receive from channel") + } else if wrapper, ok := result.(AzureWrapper); !ok { + t.Errorf("failed type assertion: got %T, want %T", result, AzureWrapper{}) + } else if data, ok := wrapper.Data.(models.Group365Owners); !ok { + t.Errorf("failed type assertion: got %T, want %T", wrapper.Data, models.Group365Owners{}) + } else if len(data.Owners) != 1 { + t.Errorf("got %v, want %v", len(data.Owners), 2) + } +} diff --git a/cmd/list-groups-o365.go b/cmd/list-groups-o365.go new file mode 100644 index 00000000..76282d9b --- /dev/null +++ b/cmd/list-groups-o365.go @@ -0,0 +1,92 @@ +// 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" + "os" + "os/signal" + "time" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/client/query" + "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(listGroups365Cmd) +} + +var listGroups365Cmd = &cobra.Command{ + Use: "groups365", + Long: "Lists Azure Active Directory Microsoft 365 Groups", + Run: listGroups365CmdImpl, + SilenceUsage: true, +} + +func listGroups365CmdImpl(cmd *cobra.Command, _ []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 azure active directory microsoft 365 groups...") + start := time.Now() + stream := listGroups365(ctx, azClient) + panicrecovery.HandleBubbledPanic(ctx, stop, log) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listGroups365(ctx context.Context, client client.AzureClient) <-chan interface{} { + out := make(chan interface{}) + + go func() { + defer panicrecovery.PanicRecovery() + defer close(out) + count := 0 + for item := range client.ListAzureADGroups365(ctx, query.GraphParams{Filter: "groupTypes/any(g:g eq 'Unified')"}) { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing Microsoft 365 groups") + return + } else { + log.V(2).Info("found Microsoft 365 group", "group", item) + count++ + group := models.Group365{ + Group365: item.Ok, + TenantId: client.TenantInfo().TenantId, + TenantName: client.TenantInfo().DisplayName, + } + if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ + Kind: enums.KindAZGroup365, + Data: group, + }); !ok { + return + } + } + } + log.Info("finished listing all Microsoft 365 groups", "count", count) + }() + + return out +} diff --git a/cmd/list-groups-o365_test.go b/cmd/list-groups-o365_test.go new file mode 100644 index 00000000..a81c6d1f --- /dev/null +++ b/cmd/list-groups-o365_test.go @@ -0,0 +1,70 @@ +// 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" + "testing" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/client/mocks" + "github.com/bloodhoundad/azurehound/v2/models/azure" + "go.uber.org/mock/gomock" +) + +func init() { + setupLogger() +} + +func TestListGroups365(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + + mockClient := mocks.NewMockAzureClient(ctrl) + mockChannel := make(chan client.AzureResult[azure.Group365]) + mockTenant := azure.Tenant{} + mockError := fmt.Errorf("I'm an error") + mockClient.EXPECT().TenantInfo().Return(mockTenant).AnyTimes() + mockClient.EXPECT().ListAzureADGroups365(gomock.Any(), gomock.Any()).Return(mockChannel) + + go func() { + defer close(mockChannel) + mockChannel <- client.AzureResult[azure.Group365]{ + Ok: azure.Group365{}, + } + mockChannel <- client.AzureResult[azure.Group365]{ + Error: mockError, + } + mockChannel <- client.AzureResult[azure.Group365]{ + Ok: azure.Group365{}, + } + }() + + channel := listGroups365(ctx, mockClient) + result := <-channel + if _, ok := result.(AzureWrapper); !ok { + t.Errorf("failed type assertion: got %T, want %T", result, AzureWrapper{}) + } + + if _, ok := <-channel; ok { + t.Error("expected channel to close from an error result but it did not") + } +} diff --git a/enums/kind.go b/enums/kind.go index db9c7993..f6c3bbfe 100644 --- a/enums/kind.go +++ b/enums/kind.go @@ -28,6 +28,9 @@ const ( KindAZGroup Kind = "AZGroup" KindAZGroupMember Kind = "AZGroupMember" KindAZGroupOwner Kind = "AZGroupOwner" + KindAZGroup365 Kind = "AZGroup365" + KindAZGroup365Member Kind = "AZGroup365Member" + KindAZGroup365Owner Kind = "AZGroup365Owner" KindAZKeyVault Kind = "AZKeyVault" KindAZKeyVaultAccessPolicy Kind = "AZKeyVaultAccessPolicy" KindAZKeyVaultContributor Kind = "AZKeyVaultContributor" diff --git a/models/azure/group365.go b/models/azure/group365.go new file mode 100644 index 00000000..c0618fd4 --- /dev/null +++ b/models/azure/group365.go @@ -0,0 +1,281 @@ +// 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 ( + "github.com/bloodhoundad/azurehound/v2/enums" +) + +// Represents an Azure Active Directory (Azure AD) group, which can be a Microsoft 365 group, or a security group. +// For more detail see https://docs.microsoft.com/en-us/graph/api/resources/group?view=graph-rest-1.0 +type Group365 struct { + DirectoryObject + + // Indicates if people external to the organization can send messages to the group. + // Default value is false. + // Returned only on $select for GET /groups/{ID} + AllowExternalSenders bool `json:"allowExternalSenders,omitempty"` + + // The list of sensitivity label pairs (label ID, label name) associated with a Microsoft 365 group. + // Returned only on $select. + // Read-only. + AssignedLabels []AssignedLabel `json:"assignedLabels,omitempty"` + + // The licenses that are assigned to the group. + // Returned only on $select. + // Supports $filter (eq) + // Read-only. + AssignedLicenses []AssignedLicense `json:"assignedLicenses,omitempty"` + + // Indicates if new members added to the group will be auto-subscribed to receive email notifications. + // You can set this property in a PATCH request for the group; do not set it in the initial POST request that + // creates the group. + // Default value is false. + // Returned only on $select for GET /groups/{ID} + AutoSubscribeNewMembers bool `json:"autoSubscribeNewMembers,omitempty"` + + // Describes a classification for the group (such as low, medium or high business impact). + // Valid values for this property are defined by creating a ClassificationList setting value, based on the template + // definition. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, startsWith) + Classification string `json:"classification,omitempty"` + + // Timestamp of when the group was created. + // The value cannot be modified and is automatically populated when the group is created. The Timestamp type + // represents date and time information using ISO 8601 format and is always in UTC time. + // For example, midnight UTC on Jan 1, 2014 is 2014-01-01T00:00:00Z. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, in). + // Read-only. + CreatedDateTime string `json:"createdDateTime,omitempty"` + + // For some Azure Active Directory objects (user, group, application), if the object is deleted, it is first + // logically deleted, and this property is updated with the date and time when the object was deleted. Otherwise, + // this property is null. If the object is restored, this property is updated to null. + DeletedDateTime string `json:"deletedDateTime,omitempty"` + + // An optional description for the group. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, startsWith) and $search. + Description string `json:"description,omitempty"` + + // The display name for the group. + // This property is required when a group is created and cannot be cleared during updates. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, in, startsWith), $search, and $orderBy. + DisplayName string `json:"displayName,omitempty"` + + // Timestamp of when the group is set to expire. The value cannot be modified and is automatically populated when + // the group is created. The Timestamp type represents date and time information using ISO 8601 format and is always + // in UTC time. + // For example, midnight UTC on Jan 1, 2014 is 2014-01-01T00:00:00Z. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, in). + // Read-only. + ExpirationDateTime string `json:"expirationDateTime,omitempty"` + + // Specifies the group type and its membership. + // If the collection contains Unified, the group is a Microsoft 365 group; otherwise, it's either a security group + // or distribution group. For details, see groups overview. + // If the collection includes DynamicMembership, the group has dynamic membership; otherwise, membership is static. + // Returned by default. + // Supports $filter (eq, NOT). + GroupTypes []string `json:"groupTypes,omitempty"` + + // Indicates whether there are members in this group that have license errors from its group-based license + // assignment. + // This property is never returned on a GET operation. + // You can use it as a $filter argument to get groups that have members with license errors (that is, filter for + // this property being true) + // Supports $filter (eq). + HasMembersWithLicenseErrors bool `json:"hasMembersWithLicenseErrors,omitempty"` + + // True if the group is not displayed in certain parts of the Outlook UI: the Address Book, address lists for + // selecting message recipients, and the Browse Groups dialog for searching groups; otherwise, false. + // Default value is false. + // Returned only on $select for GET /groups/{ID} + HideFromAddressLists bool `json:"hideFromAddressLists,omitempty"` + + // True if the group is not displayed in Outlook clients, such as Outlook for Windows and Outlook on the web; + // otherwise, false. + // Default value is false. + // Returned only on $select for GET /groups/{ID} + HideFromOutlookClients bool `json:"hideFromOutlookClients,omitempty"` + + // Indicates whether this group can be assigned to an Azure Active Directory role or not. + // Optional. + // This property can only be set while creating the group and is immutable. If set to true, the securityEnabled + // property must also be set to true and the group cannot be a dynamic group (that is, groupTypes cannot contain + // DynamicMembership). Only callers in Global administrator and Privileged role administrator roles can set this + // property. The caller must be assigned the RoleManagement.ReadWrite.Directory permission to set this property or + // update the membership of such groups. For more, see Using a group to manage Azure AD role assignments + // Returned by default. + // Supports $filter (eq, ne, NOT). + IsAssignableToRole bool `json:"isAssignableToRole,omitempty"` + + // Indicates whether the signed-in user is subscribed to receive email conversations. + // Default value is true. + // Returned only on $select for GET /groups/{ID} + IsSubscribedByMail bool `json:"isSubscribedByMail,omitempty"` + + // Indicates status of the group license assignment to all members of the group. + // Default value is false. + // Read-only. + // Returned only on $select. + LicenseProcessingState enums.LicenseProcessingState `json:"licenseProcessingState,omitempty"` + + // The SMTP address for the group, for example, "serviceadmins@contoso.onmicrosoft.com". + // Returned by default. + // Read-only. + // Supports $filter (eq, ne, NOT, ge, le, in, startsWith). + Mail string `json:"mail,omitempty"` + + // Specifies whether the group is mail-enabled. + // Required. + // Returned by default. + // Supports $filter (eq, ne, NOT). + MailEnabled bool `json:"mailEnabled,omitempty"` + + // The mail alias for the group, unique in the organization. + // Maximum length is 64 characters. + // This property can contain only characters in the ASCII character set 0 - 127 except: @ () \ [] " ; : . <> , SPACE + // Required. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, in, startsWith). + MailNickname string `json:"mailNickname,omitempty"` + + // The rule that determines members for this group if the group is a dynamic group (groupTypes contains + // DynamicMembership). For more information about the syntax of the membership rule, see Membership Rules syntax. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, startsWith). + MembershipRule string `json:"membershipRule,omitempty"` + + // Indicates whether the dynamic membership processing is on or paused. + // Returned by default. + // Supports $filter (eq, ne, NOT, in). + MembershipRuleProcessingState enums.RuleProcessingState `json:"membershipRuleProcessingState,omitempty"` + + // Indicates the last time at which the group was synced with the on-premises directory. + // The Timestamp type represents date and time information using ISO 8601 format and is always in UTC time. + // For example, midnight UTC on Jan 1, 2014 is 2014-01-01T00:00:00Z. + // Returned by default. + // Read-only. + // Supports $filter (eq, ne, NOT, ge, le, in). + OnPremisesLastSyncDateTime string `json:"onPremisesLastSyncDateTime,omitempty"` + + // Errors when using Microsoft synchronization product during provisioning. + // Returned by default. + // Supports $filter (eq, NOT). + OnPremisesProvisioningErrors []OnPremisesProvisioningError `json:"onPremisesProvisioningErrors,omitempty"` + + // Contains the on-premises SAM account name synchronized from the on-premises directory. + // The property is only populated for customers who are synchronizing their on-premises directory to Azure Active + // Directory via Azure AD Connect. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, in, startsWith). + // Read-only. + OnPremisesSamAccountName string `json:"onPremisesSamAccountName,omitempty"` + + // Contains the on-premises security identifier (SID) for the group that was synchronized from on-premises to the + // cloud. + // Returned by default. + // Supports $filter on null values. + // Read-only. + OnPremisesSecurityIdentifier string `json:"onPremisesSecurityIdentifier,omitempty"` + + // true if this group is synced from an on-premises directory; false if this group was originally synced from an + // on-premises directory but is no longer synced; null if this object has never been synced from an on-premises + // directory (default). + // Returned by default. + // Read-only. + // Supports $filter (eq, ne, NOT, in). + OnPremisesSyncEnabled bool `json:"onPremisesSyncEnabled,omitempty"` + + // The preferred data location for the Microsoft 365 group. + // By default, the group inherits the group creator's preferred data location. To set this property, the calling + // user must be assigned one of the following Azure AD roles: + // - Global Administrator + // - User Account Administrator + // - Directory Writer + // - Exchange Administrator + // - SharePoint Administrator + // + // Nullable. + // Returned by default. + PreferredDataLocation string `json:"preferredDataLocation,omitempty"` + + // The preferred language for a Microsoft 365 group. + // Should follow ISO 639-1 Code; for example en-US. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, in, startsWith). + PreferredLanguage string `json:"preferredLanguage,omitempty"` + + // Email addresses for the group that direct to the same group mailbox. + // For example: ["SMTP: bob@contoso.com", "smtp: bob@sales.contoso.com"]. + // The any operator is required to filter expressions on multi-valued properties. + // Returned by default. + // Read-only. + // Not nullable. + // Supports $filter (eq, NOT, ge, le, startsWith). + ProxyAddresses []string `json:"proxyAddresses,omitempty"` + + // Timestamp of when the group was last renewed. + // This cannot be modified directly and is only updated via the renew service action. + // The Timestamp type represents date and time information using ISO 8601 format and is always in UTC time. + // For example, midnight UTC on Jan 1, 2014 is 2014-01-01T00:00:00Z. + // Returned by default. + // Supports $filter (eq, ne, NOT, ge, le, in). + // Read-only. + RenewedDateTime string `json:"renewedDateTime,omitempty"` + + // Specifies the group behaviors that can be set for a Microsoft 365 group during creation. + // This can be set only as part of creation (POST). + ResourceBehaviorOptions []enums.ResourceBehavior `json:"resourceBehaviorOptions,omitempty"` + + // Specifies the group resources that are provisioned as part of Microsoft 365 group creation, that are not normally + // part of default group creation. + ResourceProvisioningOptions []enums.ResourceProvisioning `json:"resourceProvisioningOptions,omitempty"` + + // Specifies whether the group is a security group. + // Required. + // Returned by default. + // Supports $filter (eq, ne, NOT, in). + SecurityEnabled bool `json:"securityEnabled,omitempty"` + + // Security identifier of the group, used in Windows scenarios. + // Returned by default. + SecurityIdentifier string `json:"securityIdentifier,omitempty"` + + // Specifies a Microsoft 365 group's color theme. Possible values are Teal, Purple, Green, Blue, Pink, Orange or Red + Theme string `json:"theme,omitempty"` + + // Count of conversations that have received new posts since the signed-in user last visited the group. + // Returned only on $select for GET /groups/{ID} + UnseenCount int32 `json:"unseenCount,omitempty"` + + // Specifies the group join policy and group content visibility for groups. + // Possible values are: Private, Public, or Hiddenmembership. + // Hiddenmembership can be set only for Microsoft 365 groups, when the groups are created. + // It can't be updated later. Other values of visibility can be updated after group creation. + // If visibility value is not specified during group creation on Microsoft Graph, a security group is created as + // Private by default and Microsoft 365 group is Public. Groups assignable to roles are always Private. + // Returned by default. + // Nullable. + Visibility enums.GroupVisibility `json:"visibility,omitempty"` +} diff --git a/models/group365-member.go b/models/group365-member.go new file mode 100644 index 00000000..dcb708f9 --- /dev/null +++ b/models/group365-member.go @@ -0,0 +1,44 @@ +// 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" +) + +type Group365Member struct { + Member json.RawMessage `json:"member"` + GroupId string `json:"groupId"` +} + +func (s *Group365Member) MarshalJSON() ([]byte, error) { + output := make(map[string]any) + output["groupId"] = s.GroupId + + if member, err := OmitEmpty(s.Member); err != nil { + return nil, err + } else { + output["member"] = member + return json.Marshal(output) + } +} + +type Group365Members struct { + Members []Group365Member `json:"members"` + GroupId string `json:"groupId"` +} diff --git a/models/group365-owner.go b/models/group365-owner.go new file mode 100644 index 00000000..ae9fbbc1 --- /dev/null +++ b/models/group365-owner.go @@ -0,0 +1,44 @@ +// 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" +) + +type Group365Owner struct { + Owner json.RawMessage `json:"owner"` + GroupId string `json:"groupId"` +} + +func (s *Group365Owner) MarshalJSON() ([]byte, error) { + output := make(map[string]any) + output["groupId"] = s.GroupId + + if owner, err := OmitEmpty(s.Owner); err != nil { + return nil, err + } else { + output["owner"] = owner + return json.Marshal(output) + } +} + +type Group365Owners struct { + Owners []Group365Owner `json:"owners"` + GroupId string `json:"groupId"` +} diff --git a/models/group365.go b/models/group365.go new file mode 100644 index 00000000..0ba72c54 --- /dev/null +++ b/models/group365.go @@ -0,0 +1,28 @@ +// 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 ( + "github.com/bloodhoundad/azurehound/v2/models/azure" +) + +type Group365 struct { + azure.Group365 + TenantId string `json:"tenantId"` + TenantName string `json:"tenantName"` +}