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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,6 @@ tags

# Built Visual Studio Code Extensions
*.vsix

# AI
.claude/
34 changes: 30 additions & 4 deletions client/rest/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package rest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions client/rest/errors.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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"
}
58 changes: 44 additions & 14 deletions cmd/list-group-members.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Comment on lines 113 to 156
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t emit partial memberships as a successful result.

After a non-expired error, or after all expired-page-token attempts fail, data still gets sent as KindAZGroupMember. That preserves the original partial-data corruption mode: downstream consumers can’t distinguish incomplete group membership from a complete enumeration.

🐛 Proposed guard against sending incomplete data
 				var (
-					data  models.GroupMembers
-					count int
+					data      models.GroupMembers
+					count     int
+					completed bool
 				)
 				for attempt := 1; attempt <= maxAttempts; attempt++ {
 					data = models.GroupMembers{GroupId: id}
 					count = 0
 					expired := false
+					failed := 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
 							}
+							failed = true
 							log.Error(item.Error, "unable to continue processing members for this group",
 								"groupId", id, "attempt", attempt)
 						} else {
 							groupMember := models.GroupMember{
 								Member:  item.Ok,
@@
 						}
 					}
 
-					if !expired {
+					if !expired && !failed {
+						completed = true
 						break
 					}
+					if failed {
+						break
+					}
 					if attempt == maxAttempts {
-						log.Error(fmt.Errorf("exhausted %d retries due to Directory_ExpiredPageToken", maxAttempts),
+						log.Error(fmt.Errorf("exhausted %d attempts due to Directory_ExpiredPageToken", maxAttempts),
 							"group members may be incomplete", "groupId", id, "partialCount", count)
 					}
 				}
 
+				if !completed {
+					continue
+				}
 				if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{
 					Kind: enums.KindAZGroupMember,
 					Data: data,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/list-group-members.go` around lines 113 - 156, The loop currently always
sends `data` (models.GroupMembers) via `pipeline.SendAny` even when enumeration
was incomplete; change the logic to only emit a successful `KindAZGroupMember`
when the enumeration fully completed. Add a completion flag (e.g., `complete :=
true`) before iterating results from `client.ListAzureADGroupMembers`, set
`complete = false` when you encounter any non-expired error or when
expired-page-token retries are exhausted (`expired` true and `attempt ==
maxAttempts`), and only call `pipeline.SendAny` with the `AzureWrapper{ Kind:
enums.KindAZGroupMember, Data: data }` when `complete` is true; otherwise send
an error/failed wrapper or skip sending the member result and log the failure so
downstream consumers cannot treat partial `data` as a complete group membership.

Expand Down
Loading