diff --git a/client/client.go b/client/client.go index 81a63c7..5d15795 100644 --- a/client/client.go +++ b/client/client.go @@ -194,6 +194,7 @@ type AzureGraphClient interface { GetAzureADOrganization(ctx context.Context, selectCols []string) (*azure.Organization, error) ListAzureADGroups(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Group] + GetAzureADGroupOnPremisesSyncBehavior(ctx context.Context, objectId string) (*azure.OnPremisesSyncBehavior, error) 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] ListAzureADAppOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] diff --git a/client/groups.go b/client/groups.go index 9303a8c..36c9fc9 100644 --- a/client/groups.go +++ b/client/groups.go @@ -23,6 +23,7 @@ import ( "fmt" "github.com/SpecterOps/AzureHound/v2/client/query" + "github.com/SpecterOps/AzureHound/v2/client/rest" "github.com/SpecterOps/AzureHound/v2/constants" "github.com/SpecterOps/AzureHound/v2/models/azure" ) @@ -59,6 +60,25 @@ func (s *azureClient) ListAzureADGroupOwners(ctx context.Context, objectId strin return out } +// GetAzureADGroupOnPremisesSyncBehavior resolves a group's cloud source-of-authority state (isCloudManaged) from its +// onPremisesSyncBehavior navigation property. This is a per-group GET because the property is not returned on the +// group resource and does not support $expand. +// https://learn.microsoft.com/en-us/graph/api/onpremisessyncbehavior-get?view=graph-rest-beta +func (s *azureClient) GetAzureADGroupOnPremisesSyncBehavior(ctx context.Context, objectId string) (*azure.OnPremisesSyncBehavior, error) { + var ( + path = fmt.Sprintf("/%s/groups/%s/onPremisesSyncBehavior", constants.GraphApiBetaVersion, objectId) + response azure.OnPremisesSyncBehavior + ) + + if res, err := s.msgraph.Get(ctx, path, query.GraphParams{Select: []string{"id", "isCloudManaged"}}, nil); err != nil { + return nil, err + } else if err := rest.Decode(res.Body, &response); err != nil { + return nil, err + } else { + return &response, nil + } +} + // ListAzureADGroupMembers https://learn.microsoft.com/en-us/graph/api/group-list-members?view=graph-rest-beta func (s *azureClient) ListAzureADGroupMembers(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] { var ( diff --git a/client/mocks/client.go b/client/mocks/client.go index 86e43a1..034cf14 100644 --- a/client/mocks/client.go +++ b/client/mocks/client.go @@ -56,6 +56,21 @@ func (mr *MockAzureClientMockRecorder) CloseIdleConnections() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseIdleConnections", reflect.TypeOf((*MockAzureClient)(nil).CloseIdleConnections)) } +// GetAzureADGroupOnPremisesSyncBehavior mocks base method. +func (m *MockAzureClient) GetAzureADGroupOnPremisesSyncBehavior(ctx context.Context, objectId string) (*azure.OnPremisesSyncBehavior, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAzureADGroupOnPremisesSyncBehavior", ctx, objectId) + ret0, _ := ret[0].(*azure.OnPremisesSyncBehavior) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAzureADGroupOnPremisesSyncBehavior indicates an expected call of GetAzureADGroupOnPremisesSyncBehavior. +func (mr *MockAzureClientMockRecorder) GetAzureADGroupOnPremisesSyncBehavior(ctx, objectId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAzureADGroupOnPremisesSyncBehavior", reflect.TypeOf((*MockAzureClient)(nil).GetAzureADGroupOnPremisesSyncBehavior), ctx, objectId) +} + // GetAzureADOrganization mocks base method. func (m *MockAzureClient) GetAzureADOrganization(ctx context.Context, selectCols []string) (*azure.Organization, error) { m.ctrl.T.Helper() diff --git a/cmd/list-groups.go b/cmd/list-groups.go index f5b6bc3..315097e 100644 --- a/cmd/list-groups.go +++ b/cmd/list-groups.go @@ -77,6 +77,17 @@ func listGroups(ctx context.Context, client client.AzureClient) <-chan interface TenantId: client.TenantInfo().TenantId, TenantName: client.TenantInfo().DisplayName, } + // A group can only be an Entra Cloud Sync group-writeback target if it carries an on-premises SID to + // match against, so only resolve the cloud source-of-authority flag (isCloudManaged) for those groups. + // The flag lives on the onPremisesSyncBehavior navigation property, which is not returned on the group + // resource and does not support $expand, hence the per-group GET. + if group.OnPremisesSecurityIdentifier != "" { + if syncBehavior, err := client.GetAzureADGroupOnPremisesSyncBehavior(ctx, group.Id); err != nil { + log.V(2).Info("unable to resolve onPremisesSyncBehavior for group", "groupId", group.Id, "error", err) + } else if syncBehavior != nil { + group.IsCloudManaged = syncBehavior.IsCloudManaged + } + } if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ Kind: enums.KindAZGroup, Data: group, diff --git a/cmd/list-groups_test.go b/cmd/list-groups_test.go index 34370ba..4dfc74f 100644 --- a/cmd/list-groups_test.go +++ b/cmd/list-groups_test.go @@ -24,6 +24,7 @@ import ( "github.com/SpecterOps/AzureHound/v2/client" "github.com/SpecterOps/AzureHound/v2/client/mocks" + "github.com/SpecterOps/AzureHound/v2/models" "github.com/SpecterOps/AzureHound/v2/models/azure" "go.uber.org/mock/gomock" ) @@ -68,3 +69,127 @@ func TestListGroups(t *testing.T) { t.Error("expected channel to close from an error result but it did not") } } + +func TestListGroupsCloudManaged(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + + mockClient := mocks.NewMockAzureClient(ctrl) + mockChannel := make(chan client.AzureResult[azure.Group]) + mockTenant := azure.Tenant{} + cloudManaged := true + + mockClient.EXPECT().TenantInfo().Return(mockTenant).AnyTimes() + mockClient.EXPECT().ListAzureADGroups(gomock.Any(), gomock.Any()).Return(mockChannel) + // The group carries an on-premises SID, so collection resolves its onPremisesSyncBehavior exactly once. + mockClient.EXPECT(). + GetAzureADGroupOnPremisesSyncBehavior(gomock.Any(), "group-with-sid"). + Return(&azure.OnPremisesSyncBehavior{IsCloudManaged: &cloudManaged}, nil). + Times(1) + + go func() { + defer close(mockChannel) + mockChannel <- client.AzureResult[azure.Group]{ + Ok: azure.Group{ + DirectoryObject: azure.DirectoryObject{Id: "group-with-sid"}, + OnPremisesSecurityIdentifier: "S-1-5-21-111-222-333-1369", + }, + } + }() + + channel := listGroups(ctx, mockClient) + wrapper, ok := (<-channel).(AzureWrapper) + if !ok { + t.Fatalf("failed type assertion: got non-AzureWrapper result") + } + group, ok := wrapper.Data.(models.Group) + if !ok { + t.Fatalf("failed type assertion on Data: got %T, want models.Group", wrapper.Data) + } + if group.IsCloudManaged == nil || !*group.IsCloudManaged { + t.Errorf("expected IsCloudManaged=true, got %v", group.IsCloudManaged) + } +} + +// A group without an on-premises SID can never be a writeback target, so collection must NOT spend a +// per-group onPremisesSyncBehavior call on it. The strict mock controller fails the test if the call happens +// (no EXPECT registered), and the emitted group must leave IsCloudManaged unresolved (nil). +func TestListGroupsCloudManagedSkippedWithoutSID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + + mockClient := mocks.NewMockAzureClient(ctrl) + mockChannel := make(chan client.AzureResult[azure.Group]) + mockTenant := azure.Tenant{} + + mockClient.EXPECT().TenantInfo().Return(mockTenant).AnyTimes() + mockClient.EXPECT().ListAzureADGroups(gomock.Any(), gomock.Any()).Return(mockChannel) + // No GetAzureADGroupOnPremisesSyncBehavior EXPECT: any such call is an unexpected-call failure. + + go func() { + defer close(mockChannel) + mockChannel <- client.AzureResult[azure.Group]{ + Ok: azure.Group{DirectoryObject: azure.DirectoryObject{Id: "cloud-only-group"}}, + } + }() + + channel := listGroups(ctx, mockClient) + wrapper, ok := (<-channel).(AzureWrapper) + if !ok { + t.Fatalf("failed type assertion: got non-AzureWrapper result") + } + group, ok := wrapper.Data.(models.Group) + if !ok { + t.Fatalf("failed type assertion on Data: got %T, want models.Group", wrapper.Data) + } + if group.IsCloudManaged != nil { + t.Errorf("expected IsCloudManaged=nil for a group without an on-premises SID, got %v", *group.IsCloudManaged) + } +} + +// If resolving onPremisesSyncBehavior fails (e.g. the beta endpoint 404s or the token lacks the permission), +// collection must tolerate it: the group is still emitted, just with IsCloudManaged unresolved (nil). +func TestListGroupsCloudManagedErrorTolerated(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + + mockClient := mocks.NewMockAzureClient(ctrl) + mockChannel := make(chan client.AzureResult[azure.Group]) + mockTenant := azure.Tenant{} + + mockClient.EXPECT().TenantInfo().Return(mockTenant).AnyTimes() + mockClient.EXPECT().ListAzureADGroups(gomock.Any(), gomock.Any()).Return(mockChannel) + mockClient.EXPECT(). + GetAzureADGroupOnPremisesSyncBehavior(gomock.Any(), "group-with-sid"). + Return(nil, fmt.Errorf("insufficient privileges")). + Times(1) + + go func() { + defer close(mockChannel) + mockChannel <- client.AzureResult[azure.Group]{ + Ok: azure.Group{ + DirectoryObject: azure.DirectoryObject{Id: "group-with-sid"}, + OnPremisesSecurityIdentifier: "S-1-5-21-111-222-333-1369", + }, + } + }() + + channel := listGroups(ctx, mockClient) + wrapper, ok := (<-channel).(AzureWrapper) + if !ok { + t.Fatalf("failed type assertion: got non-AzureWrapper result") + } + group, ok := wrapper.Data.(models.Group) + if !ok { + t.Fatalf("failed type assertion on Data: got %T, want models.Group", wrapper.Data) + } + if group.IsCloudManaged != nil { + t.Errorf("expected IsCloudManaged=nil when resolution errors, got %v", *group.IsCloudManaged) + } +} diff --git a/models/azure/group.go b/models/azure/group.go index 753ec77..a3505c8 100644 --- a/models/azure/group.go +++ b/models/azure/group.go @@ -21,6 +21,19 @@ import ( "github.com/SpecterOps/AzureHound/v2/enums" ) +// OnPremisesSyncBehavior represents the onPremisesSyncBehavior navigation property of a group, which exposes the +// group's cloud source-of-authority state. +// For more detail see https://learn.microsoft.com/en-us/graph/api/resources/onpremisessyncbehavior?view=graph-rest-beta +type OnPremisesSyncBehavior struct { + // The unique identifier of the group this sync behavior describes. + Id string `json:"id,omitempty"` + + // true if the group's source of authority is the cloud (Entra), i.e. the group is cloud-managed and written back + // to on-premises AD by Entra Cloud Sync group provisioning; false if the group remains on-premises-authoritative. + // nil if absent from the response. + IsCloudManaged *bool `json:"isCloudManaged,omitempty"` +} + // 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 Group struct { @@ -207,6 +220,15 @@ type Group struct { // Supports $filter (eq, ne, NOT, in). OnPremisesSyncEnabled bool `json:"onPremisesSyncEnabled,omitempty"` + // IsCloudManaged indicates whether the group's source of authority is the cloud (Entra), i.e. the group is + // cloud-managed and Entra Cloud Sync group provisioning writes its membership back to the SID-matched + // on-premises AD group (on-premises edits are overwritten). + // This value is NOT returned on the group resource and the onPremisesSyncBehavior navigation property does not + // support $expand; it is resolved per-group via GET /groups/{id}/onPremisesSyncBehavior during collection and is + // only populated for groups that carry an on-premises security identifier. + // nil if not resolved. + IsCloudManaged *bool `json:"isCloudManaged,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: