Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions client/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 (
Expand Down
15 changes: 15 additions & 0 deletions client/mocks/client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions cmd/list-groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
125 changes: 125 additions & 0 deletions cmd/list-groups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
22 changes: 22 additions & 0 deletions models/azure/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
Loading