From d78df44c0818031581fd6defaad88e1eddc9d952 Mon Sep 17 00:00:00 2001 From: Filip Jodoin Date: Tue, 21 Apr 2026 16:41:34 -0400 Subject: [PATCH 1/2] fix: retry group member enumeration on Directory_ExpiredPageToken --- client/rest/client.go | 34 ++++++++++++++++++++--- client/rest/errors.go | 46 +++++++++++++++++++++++++++++++ cmd/list-group-members.go | 58 +++++++++++++++++++++++++++++---------- 3 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 client/rest/errors.go diff --git a/client/rest/client.go b/client/rest/client.go index 741193eb..0084ddda 100644 --- a/client/rest/client.go +++ b/client/rest/client.go @@ -22,6 +22,7 @@ package rest import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -216,13 +217,38 @@ func (s *restClient) send(req *http.Request) (*http.Response, error) { ExponentialBackoff(retry) continue } else { - // Not a status code that warrants a retry + // Not a status code that warrants a retry. Read the body once + // and try to decode it as a Microsoft Graph-shaped error + // envelope so callers can programmatically detect specific + // codes (e.g. Directory_ExpiredPageToken). Fall back to the + // original map-stringification behavior for non-Graph shapes + // (e.g. Resource Manager errors) so existing callers don't + // regress. + bodyBytes, readErr := io.ReadAll(res.Body) + res.Body.Close() + if readErr != nil || len(bodyBytes) == 0 { + return nil, fmt.Errorf("malformed error response, status code: %d", res.StatusCode) + } + + var graphEnvelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(bodyBytes, &graphEnvelope); err == nil && graphEnvelope.Error.Code != "" { + return nil, &GraphError{ + StatusCode: res.StatusCode, + Code: graphEnvelope.Error.Code, + Message: graphEnvelope.Error.Message, + } + } + var errRes map[string]interface{} - if err := Decode(res.Body, &errRes); err != nil { + if err := json.Unmarshal(bodyBytes, &errRes); err != nil { return nil, fmt.Errorf("malformed error response, status code: %d", res.StatusCode) - } else { - return nil, fmt.Errorf("%v", errRes) } + return nil, fmt.Errorf("%v", errRes) } } else { // Response OK diff --git a/client/rest/errors.go b/client/rest/errors.go new file mode 100644 index 00000000..0c411610 --- /dev/null +++ b/client/rest/errors.go @@ -0,0 +1,46 @@ +// 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 rest + +import ( + "errors" + "fmt" +) + +// GraphError is a structured representation of a Microsoft Graph error +// response body ({"error": {"code": "...", "message": "..."}}). It is +// returned by the REST layer when a 4xx response decodes to that shape so +// callers can programmatically detect specific error codes. +type GraphError struct { + StatusCode int + Code string + Message string +} + +func (e *GraphError) Error() string { + return fmt.Sprintf("graph error %d: %s - %s", e.StatusCode, e.Code, e.Message) +} + +// IsExpiredPageToken reports whether err (or anything it wraps) is a Graph +// error with code "Directory_ExpiredPageToken", indicating the pagination +// cursor in @odata.nextLink has expired and the enumeration must be +// restarted from scratch. +func IsExpiredPageToken(err error) bool { + var ge *GraphError + return errors.As(err, &ge) && ge.Code == "Directory_ExpiredPageToken" +} diff --git a/cmd/list-group-members.go b/cmd/list-group-members.go index 8b0df2b1..967537e7 100644 --- a/cmd/list-group-members.go +++ b/cmd/list-group-members.go @@ -27,6 +27,7 @@ import ( "github.com/bloodhoundad/azurehound/v2/client" "github.com/bloodhoundad/azurehound/v2/client/query" + "github.com/bloodhoundad/azurehound/v2/client/rest" "github.com/bloodhoundad/azurehound/v2/config" "github.com/bloodhoundad/azurehound/v2/enums" "github.com/bloodhoundad/azurehound/v2/models" @@ -102,25 +103,54 @@ func listGroupMembers(ctx context.Context, client client.AzureClient, groups <-c defer panicrecovery.PanicRecovery() defer wg.Done() for id := range stream { + // Microsoft Graph pagination cursors embedded in @odata.nextLink + // can expire mid-enumeration for large groups (error code + // Directory_ExpiredPageToken). This is independent of the OAuth + // access token's lifetime. When it happens, the only recourse is + // to restart the member enumeration from scratch so Graph issues + // a fresh cursor. Retry per-group with a bounded attempt count. + const maxAttempts = 4 var ( - data = models.GroupMembers{ - GroupId: id, - } - count = 0 + data models.GroupMembers + count int ) - for item := range client.ListAzureADGroupMembers(ctx, id, params) { - if item.Error != nil { - log.Error(item.Error, "unable to continue processing members for this group", "groupId", id) - } else { - groupMember := models.GroupMember{ - Member: item.Ok, - GroupId: id, + for attempt := 1; attempt <= maxAttempts; attempt++ { + data = models.GroupMembers{GroupId: id} + count = 0 + expired := false + + for item := range client.ListAzureADGroupMembers(ctx, id, params) { + if item.Error != nil { + if rest.IsExpiredPageToken(item.Error) { + expired = true + log.Info("page token expired mid-enumeration, will retry group", + "groupId", id, "attempt", attempt, "collectedSoFar", count) + // Producer already returned after emitting the error; + // the channel will close and this range will exit. + continue + } + log.Error(item.Error, "unable to continue processing members for this group", + "groupId", id, "attempt", attempt) + } else { + groupMember := models.GroupMember{ + Member: item.Ok, + GroupId: id, + } + log.V(2).Info("found group member", "groupId", groupMember.GroupId) + count++ + data.Members = append(data.Members, groupMember) } - log.V(2).Info("found group member", "groupId", groupMember.GroupId) - count++ - data.Members = append(data.Members, groupMember) + } + + if !expired { + break + } + if attempt == maxAttempts { + log.Error(fmt.Errorf("exhausted %d retries due to Directory_ExpiredPageToken", maxAttempts), + "group members may be incomplete", "groupId", id, "partialCount", count) } } + if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{ Kind: enums.KindAZGroupMember, Data: data, From 5b809681f840a5f9262a1d15343b6e6962eddebd Mon Sep 17 00:00:00 2001 From: Filip Jodoin Date: Tue, 21 Apr 2026 16:44:41 -0400 Subject: [PATCH 2/2] Adding changes --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ad3a3455..83b889c0 100644 --- a/.gitignore +++ b/.gitignore @@ -237,3 +237,6 @@ tags # Built Visual Studio Code Extensions *.vsix + +# AI +.claude/