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
2 changes: 2 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ type azureClient struct {

type AzureGraphClient interface {
GetAzureADOrganization(ctx context.Context, selectCols []string) (*azure.Organization, error)
GetAzureADTenantInfoById(ctx context.Context, tenantId string) (azure.Tenant, error)

Comment on lines +195 to 196
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 | 🔴 Critical | ⚡ Quick win

Regenerate mocks after expanding AzureGraphClient.

The interface changes at Line 195 and Line 206 currently break type-checking in tests because mocks.MockAzureClient no longer implements client.AzureClient.

Also applies to: 206-206

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/client.go` around lines 195 - 196, The AzureClient interface was
expanded (e.g., GetAzureADTenantInfoById and other AzureGraphClient-related
additions) and existing mocks no longer implement client.AzureClient; regenerate
the mocks so mocks.MockAzureClient matches the updated interface. Run your
mock-generation tool (e.g., mockgen or the project's generator) targeting the
client.AzureClient interface (including methods like GetAzureADTenantInfoById
and any AzureGraphClient methods) to overwrite/update mocks.MockAzureClient,
then run tests to ensure the mock satisfies the new 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]
Expand All @@ -202,6 +203,7 @@ type AzureGraphClient interface {
ListAzureADUsers(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.User]
ListAzureADRoleAssignments(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.UnifiedRoleAssignment]
ListAzureADRoles(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Role]
ListAzureADPartners(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Partner]
ListAzureADServicePrincipalOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage]
ListAzureADServicePrincipals(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.ServicePrincipal]
ListAzureDeviceRegisteredOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage]
Expand Down
41 changes: 41 additions & 0 deletions client/partners.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (C) 2026 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 client

import (
"context"
"fmt"

"github.com/bloodhoundad/azurehound/v2/client/query"
"github.com/bloodhoundad/azurehound/v2/constants"
"github.com/bloodhoundad/azurehound/v2/models/azure"
)

// ListAzureADPartners
// Attempts to list partners using the (undocumented) `/directory/partners` API that can be
// seen being called when visiting partner relationships tab in Entra ID <https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/PartnerRelationships>
func (s *azureClient) ListAzureADPartners(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Partner] {
var (
out = make(chan AzureResult[azure.Partner])
path = fmt.Sprintf("/%s/directory/partners", constants.GraphApiVersion)
)

go getAzureObjectList[azure.Partner](s.msgraph, ctx, path, params, out)

return out
}
2 changes: 1 addition & 1 deletion client/role_assignments.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
func (s *azureClient) ListAzureADRoleAssignments(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.UnifiedRoleAssignment] {
var (
out = make(chan AzureResult[azure.UnifiedRoleAssignment])
path = fmt.Sprintf("/%s/roleManagement/directory/roleAssignments", constants.GraphApiVersion)
path = fmt.Sprintf("/%s/roleManagement/directory/roleAssignments", constants.GraphApiBetaVersion)
)

if params.Top == 0 {
Expand Down
15 changes: 15 additions & 0 deletions client/tenants.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ func (s *azureClient) GetAzureADTenants(ctx context.Context, includeAllTenantCat
}
}

func (s *azureClient) GetAzureADTenantInfoById(ctx context.Context, tenantId string) (azure.Tenant, error) {
var (
path = fmt.Sprintf("/%s/tenantRelationships/findTenantInformationByTenantId(tenantId='%s')", constants.GraphApiVersion, tenantId)
headers map[string]string
response azure.Tenant
)
if res, err := s.msgraph.Get(ctx, path, query.GraphParams{}, headers); err != nil {
return response, err
} else if err := rest.Decode(res.Body, &response); err != nil {
return response, err
} else {
return response, nil
}
}

// ListAzureADTenants https://learn.microsoft.com/en-us/rest/api/subscription/tenants/list?view=rest-subscription-2020-01-01
func (s *azureClient) ListAzureADTenants(ctx context.Context, includeAllTenantCategories bool) <-chan AzureResult[azure.Tenant] {
var (
Expand Down
5 changes: 4 additions & 1 deletion cmd/list-azure-ad.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{
servicePrincipals2 = make(chan interface{})
servicePrincipals3 = make(chan interface{})

tenants = make(chan interface{})
tenants = make(chan interface{})
partnerTenants = make(chan interface{})
)

// Enumerate Apps, AppOwners and AppMembers
Expand All @@ -99,6 +100,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{

// Enumerate Tenants
pipeline.Tee(ctx.Done(), listTenants(ctx, client), tenants)
pipeline.Tee(ctx.Done(), listPartners(ctx, client), partnerTenants)

// Enumerate Users
users := listUsers(ctx, client)
Expand Down Expand Up @@ -130,6 +132,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{
servicePrincipalOwners,
servicePrincipals,
tenants,
partnerTenants,
users,
unifiedRoleEligibilitySchedules,
unifiedRoleManagementPolicyAssignments,
Expand Down
224 changes: 224 additions & 0 deletions cmd/list-partners.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// Copyright (C) 2026 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 cmd

import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"regexp"
"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/models/azure"
"github.com/bloodhoundad/azurehound/v2/panicrecovery"
"github.com/bloodhoundad/azurehound/v2/pipeline"
"github.com/spf13/cobra"
)

var externalLinkSuffix = regexp.MustCompile(`\s@\([^)]*\)`)

func init() {
listRootCmd.AddCommand(listPartnersCmd)
}

var listPartnersCmd = &cobra.Command{
Use: "partners",
Long: "Lists Azure Active Directory Delegated Partners",
Run: listPartnersCmdImpl,
SilenceUsage: true,
}

func listPartnersCmdImpl(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 active directory delegated partners...")
start := time.Now()
stream := listPartners(ctx, azClient)
panicrecovery.HandleBubbledPanic(ctx, stop, log)
outputStream(ctx, stream)
duration := time.Since(start)
log.Info("collection completed", "duration", duration.String())
}

func listPartners(ctx context.Context, client client.AzureClient) <-chan interface{} {
out := make(chan interface{})

go func() {
defer panicrecovery.PanicRecovery()
defer close(out)
count := 0
partnerTenants := make(map[string]azure.Tenant, 10)

for partner := range client.ListAzureADPartners(ctx, query.GraphParams{}) {
if partner.Error != nil {
log.Error(partner.Error, "unable to continue processing partners")
return
}

log.V(2).Info("found partner", "companyName", partner.Ok.CompanyName, "partnerTenantId", partner.Ok.PartnerTenantId)
count++

// Begin by fetching the partner tenant information
externalTenant, err := client.GetAzureADTenantInfoById(ctx, partner.Ok.PartnerTenantId)
if err != nil {
log.Error(err, "failed to retrieve tenant information for external partner", "companyName", partner.Ok.CompanyName, "partnerTenantId", partner.Ok.PartnerTenantId)
}
Comment on lines +87 to +89
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 | ⚡ Quick win

Skip emission when external tenant lookup fails.

After the error at Line 87, execution continues and emits a zero-value tenant (e.g., Id becomes /tenants/) and inserts an empty-key entry into partnerTenants.

🐛 Proposed fix
 			externalTenant, err := client.GetAzureADTenantInfoById(ctx, partner.Ok.PartnerTenantId)
 			if err != nil {
 				log.Error(err, "failed to retrieve tenant information for external partner", "companyName", partner.Ok.CompanyName, "partnerTenantId", partner.Ok.PartnerTenantId)
+				continue
 			}

Also applies to: 91-105

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/list-partners.go` around lines 87 - 89, The tenant lookup error handler
logs the failure but then continues and adds a zero-value tenant into
partnerTenants; change the error branch in the loop that calls the external
tenant retrieval (the block that logs using log.Error with "failed to retrieve
tenant information for external partner" and references partner.Ok.CompanyName /
partner.Ok.PartnerTenantId) to return early from that iteration (e.g., skip
inserting into partnerTenants) by continuing the loop after logging. Apply the
same fix to the similar error-handling blocks later in the same loop (the region
covering lines ~91-105) so that when err != nil you do not populate
partnerTenants with an empty tenant entry.


externalTenant.Id = fmt.Sprintf("/tenants/%s", externalTenant.TenantId)
externalTenant.TenantType = partner.Ok.CompanyType

if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{
Kind: enums.KindAZTenant,
Data: models.Tenant{
Tenant: externalTenant,
External: true,
},
}); !ok {
return
}

partnerTenants[externalTenant.TenantId] = externalTenant
}
log.Info("finished listing all delegated partners", "count", count)

count = 0

// This part is a bit hacky but i'll try to explain what's going on:
//
// For partners, associated pricipal data is stored in their tenant.
// This means that you unfortunately can't just directly query our own
// list of groups/users/service principals and get back the principal
// information directly. For some reason Microsoft decided to lock this
// info behind calls that let you query information via `$expand` queries.
//
// While I'd love to filter based on `principalOrganizationId`, this field
// seems to be some dynamic magic field on the backend and therefor can't be
// filtered on. Morover, if you just use `$expand` on `principal` and try to
// list all role assignments, you still won't get the information you're looking
// for. So far the only way I'm able to reliably filter external tenant's
// information is by passing a `roleDefinitionId` filter on `roleAssignments`
// which results in the `principal` field and the `principalOrganizationId` field
// being present.
//
// If you find a more efficient way of getting this info I'd love to see an
// improved version :)
observedPrincipalIds := make(map[string]bool)

for role := range client.ListAzureADRoles(ctx, query.GraphParams{}) {
if role.Error != nil {
log.Error(role.Error, "unable to continue processing partner roles")
break
}

for item := range client.ListAzureADRoleAssignments(ctx, query.GraphParams{
Filter: fmt.Sprintf("roleDefinitionId eq '%s'", role.Ok.Id),
Expand: "principal",
}) {
if item.Error != nil {
log.Error(item.Error, "unable to continue processing partner role assignments")
break
}

tenant, exists := partnerTenants[item.Ok.PrincipalOrganizationId]
if !exists {
continue
}

var header struct {
Type string `json:"@odata.type"`
Id string `json:"Id"`
DisplayName string `json:"DisplayName,omitempty"`
}

if err := json.Unmarshal(item.Ok.Principal, &header); err != nil {
log.Error(err, "unable to determine principal type")
continue
}

if _, ok := observedPrincipalIds[header.Id]; ok {
continue
}

var (
kind enums.Kind
data any
)

switch header.Type {
case "#microsoft.graph.user":
var user azure.User
if err := json.Unmarshal(item.Ok.Principal, &user); err != nil {
log.Error(err, "unable to unmarshal user principal")
continue
}
user.DisplayName = externalLinkSuffix.ReplaceAllString(user.DisplayName, "")
kind = enums.KindAZUser
data = models.User{User: user, TenantId: tenant.TenantId, TenantName: tenant.DisplayName}
log.V(2).Info("found partner user information", "id", item.Ok.Id)

case "#microsoft.graph.group":
var group azure.Group
if err := json.Unmarshal(item.Ok.Principal, &group); err != nil {
log.Error(err, "unable to unmarshal group principal")
continue
}
group.DisplayName = externalLinkSuffix.ReplaceAllString(group.DisplayName, "")
kind = enums.KindAZGroup
data = models.Group{Group: group, TenantId: tenant.TenantId, TenantName: tenant.DisplayName}
log.V(2).Info("found partner group information", "id", item.Ok.Id)

case "#microsoft.graph.servicePrincipal":
var sp azure.ServicePrincipal
if err := json.Unmarshal(item.Ok.Principal, &sp); err != nil {
log.Error(err, "unable to unmarshal service principal")
continue
}
sp.DisplayName = externalLinkSuffix.ReplaceAllString(sp.DisplayName, "")
kind = enums.KindAZServicePrincipal
data = models.ServicePrincipal{ServicePrincipal: sp, TenantId: tenant.TenantId, TenantName: tenant.DisplayName}
log.V(2).Info("found partner service principal information", "id", item.Ok.Id)

default:
log.V(2).Info("skipping unknown principal type", "type", header.Type)
continue
}

observedPrincipalIds[header.Id] = true

if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{Kind: kind, Data: data}); !ok {
break
}
Comment on lines +212 to +214
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 | 🟡 Minor | ⚡ Quick win

Return on canceled output instead of only breaking inner loop.

At Line 212, if pipeline.SendAny fails, break only exits the current role-assignment loop. The goroutine can continue with outer iterations and extra calls despite cancellation.

🐛 Proposed fix
 				if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{Kind: kind, Data: data}); !ok {
-					break
+					return
 				}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{Kind: kind, Data: data}); !ok {
break
}
if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{Kind: kind, Data: data}); !ok {
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/list-partners.go` around lines 212 - 214, The goroutine currently uses
break when pipeline.SendAny(ctx.Done(), out, AzureWrapper{Kind: kind, Data:
data}) returns false, which only exits the inner role-assignment loop and allows
the outer loop to continue; change this to stop the goroutine immediately (e.g.,
return from the goroutine) when SendAny indicates cancellation so no further
iterations or sends occur. Locate the call to pipeline.SendAny and replace the
inner-loop-only break with an immediate function return (or otherwise break out
of all loops) to ensure that on ctx cancellation no more AzureWrapper sends or
outer-loop work is performed. Ensure any necessary deferred cleanup still runs
after returning.


count++
}
}

log.Info("finished listing all delegated partner principals", "count", count)
}()

return out
}
4 changes: 3 additions & 1 deletion cmd/list-tenants.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func listTenants(ctx context.Context, client client.AzureClient) <-chan interfac
Data: models.Tenant{
Tenant: collectedTenant,
Collected: true,
External: false,
},
}); !ok {
return
Expand All @@ -89,7 +90,8 @@ func listTenants(ctx context.Context, client client.AzureClient) <-chan interfac
if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{
Kind: enums.KindAZTenant,
Data: models.Tenant{
Tenant: item.Ok,
Tenant: item.Ok,
External: false,
},
}); !ok {
return
Expand Down
Loading
Loading