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"`
+}