diff --git a/client/client.go b/client/client.go index 70b57e76..840039a2 100644 --- a/client/client.go +++ b/client/client.go @@ -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) ListAzureADGroups(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Group] ListAzureADGroupMembers(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] @@ -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] diff --git a/client/partners.go b/client/partners.go new file mode 100644 index 00000000..5ce53f1d --- /dev/null +++ b/client/partners.go @@ -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 . + +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 +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 +} diff --git a/client/role_assignments.go b/client/role_assignments.go index f742f545..8aaa9515 100644 --- a/client/role_assignments.go +++ b/client/role_assignments.go @@ -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 { diff --git a/client/tenants.go b/client/tenants.go index 4aa89660..a2339146 100644 --- a/client/tenants.go +++ b/client/tenants.go @@ -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 ( diff --git a/cmd/list-azure-ad.go b/cmd/list-azure-ad.go index be13b148..eab31efe 100644 --- a/cmd/list-azure-ad.go +++ b/cmd/list-azure-ad.go @@ -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 @@ -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) @@ -130,6 +132,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{ servicePrincipalOwners, servicePrincipals, tenants, + partnerTenants, users, unifiedRoleEligibilitySchedules, unifiedRoleManagementPolicyAssignments, diff --git a/cmd/list-partners.go b/cmd/list-partners.go new file mode 100644 index 00000000..18a32113 --- /dev/null +++ b/cmd/list-partners.go @@ -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 . + +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) + } + + 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 + } + + count++ + } + } + + log.Info("finished listing all delegated partner principals", "count", count) + }() + + return out +} diff --git a/cmd/list-tenants.go b/cmd/list-tenants.go index 867ee65d..dbc3da1b 100644 --- a/cmd/list-tenants.go +++ b/cmd/list-tenants.go @@ -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 @@ -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 diff --git a/models/azure/partner.go b/models/azure/partner.go new file mode 100644 index 00000000..bba4c355 --- /dev/null +++ b/models/azure/partner.go @@ -0,0 +1,71 @@ +// 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 . + +package azure + +// Specifies a delegated third-party partner object inside of Entra ID. +// A visual overview can be seen at +// More information +type Partner struct { + // Tenant ID of the external partner + PartnerTenantId string `json:"partnerTenantId,omitempty"` + + // What kind of external partner? + // Scraped a bunch of possible values from: + // + // Observed variants: + // - microsoftSupport + // - breadthPartner + // - breadthPartnerDelegatedAdmin + // - syndicatePartner + // - resellerPartnerDelegatedAdmin (reseller) + // - valueAddedResellerPartnerDelegatedAdmin (indiret reseller) + // + CompanyType string `json:"companyType,omitempty"` + + // The name of the external partner + CompanyName string `json:"companyName,omitempty"` + + // Link to the partner sales portal + CommerceUrl string `json:"commerceUrl,omitempty"` + + // Link to the partner help portal + HelpUrl string `json:"helpUrl,omitempty"` + + // Link to the partner support portal + // Unsure how this is different from the `HelpUrl` + SupportUrl string `json:"supportUrl,omitempty"` + + // List of telephone numbers used for support + SupportTelephones []string `json:"supportTelephones,omitempty"` + + // List of e-mail addresses used for support + SupportEmails []string `json:"supportEmails,omitempty"` + + // What type of contract does the current tenant have with the partner? + // Scraped values from: + // + // Observed variants: + // - resellerPartnerContract + // - breadthPartnerContract + ContractType string `json:"contractType,omitempty"` + + // List of Role ID's + // Unsure what type this is since the observed versions had no data + // Assuming string since that's the most logical based on the naming convention + RoleIDs []string `json:"roleIds,omitempty"` +} diff --git a/models/azure/unified_role_assignment.go b/models/azure/unified_role_assignment.go index fc60a5fd..592dc09e 100644 --- a/models/azure/unified_role_assignment.go +++ b/models/azure/unified_role_assignment.go @@ -24,13 +24,17 @@ type UnifiedRoleAssignment struct { // Identifier of the role definition the assignment is for. // Read only. - // Supports $filer (eq, in). + // Supports $filter (eq, in). RoleDefinitionId string `json:"roleDefinitionId,omitempty"` // Identifier of the principal to which the assignment is granted. // Supports $filter (eq, in). PrincipalId string `json:"principalId,omitempty"` + // Identifier of the organization (tenant) the principal belongs to + // Read only and does not support $filter. + PrincipalOrganizationId string `json:"principalOrganizationId,omitempty"` + // Identifier of the directory object representing the scope of the assignment. // Either this property or appScopeId is required. // The scope of an assignment determines the set of resources for which the principal has been granted access. diff --git a/models/tenant.go b/models/tenant.go index 7fed704a..102a3054 100644 --- a/models/tenant.go +++ b/models/tenant.go @@ -22,4 +22,5 @@ import "github.com/bloodhoundad/azurehound/v2/models/azure" type Tenant struct { azure.Tenant Collected bool `json:"collected,omitempty"` + External bool `json:"external,omitempty"` }