From dadab60d6ae180b69a8ce8f8274a52ecba4eb2ef Mon Sep 17 00:00:00 2001 From: hotnops Date: Fri, 19 Dec 2025 09:39:15 -0800 Subject: [PATCH 1/6] Adding Application FIC collection --- client/apps.go | 15 +++ client/client.go | 1 + client/mocks/client.go | 14 +++ ...list-app-federated-identity-credentials.go | 116 ++++++++++++++++++ cmd/list-azure-ad.go | 4 +- enums/kind.go | 1 + models/app-fic.go | 44 +++++++ 7 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 cmd/list-app-federated-identity-credentials.go create mode 100644 models/app-fic.go diff --git a/client/apps.go b/client/apps.go index 80ce4fa4..e8df6b84 100644 --- a/client/apps.go +++ b/client/apps.go @@ -59,3 +59,18 @@ func (s *azureClient) ListAzureADAppOwners(ctx context.Context, objectId string, return out } + +func (s *azureClient) ListAzureADAppFICs(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] { + var ( + out = make(chan AzureResult[json.RawMessage]) + path = fmt.Sprintf("/%s/applications/%s/federatedIdentityCredentials/", constants.GraphApiBetaVersion, objectId) + ) + + if params.Top == 0 { + params.Top = 99 + } + + go getAzureObjectList[json.RawMessage](s.msgraph, ctx, path, params, out) + + return out +} diff --git a/client/client.go b/client/client.go index 47a777bd..1451be07 100644 --- a/client/client.go +++ b/client/client.go @@ -181,6 +181,7 @@ type AzureGraphClient interface { 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] + ListAzureADAppFICs(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] ListAzureADApps(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Application] ListAzureADUsers(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.User] ListAzureADRoleAssignments(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.UnifiedRoleAssignment] diff --git a/client/mocks/client.go b/client/mocks/client.go index 8ae588bc..5fd9f8b4 100644 --- a/client/mocks/client.go +++ b/client/mocks/client.go @@ -86,6 +86,20 @@ func (mr *MockAzureClientMockRecorder) GetAzureADTenants(ctx, includeAllTenantCa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAzureADTenants", reflect.TypeOf((*MockAzureClient)(nil).GetAzureADTenants), ctx, includeAllTenantCategories) } +// ListAzureADAppFICs mocks base method. +func (m *MockAzureClient) ListAzureADAppFICs(ctx context.Context, objectId string, params query.GraphParams) <-chan client.AzureResult[json.RawMessage] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAzureADAppFICs", ctx, objectId, params) + ret0, _ := ret[0].(<-chan client.AzureResult[json.RawMessage]) + return ret0 +} + +// ListAzureADAppFICs indicates an expected call of ListAzureADAppFICs. +func (mr *MockAzureClientMockRecorder) ListAzureADAppFICs(ctx, objectId, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAzureADAppFICs", reflect.TypeOf((*MockAzureClient)(nil).ListAzureADAppFICs), ctx, objectId, params) +} + // ListAzureADAppOwners mocks base method. func (m *MockAzureClient) ListAzureADAppOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan client.AzureResult[json.RawMessage] { m.ctrl.T.Helper() diff --git a/cmd/list-app-federated-identity-credentials.go b/cmd/list-app-federated-identity-credentials.go new file mode 100644 index 00000000..679f0489 --- /dev/null +++ b/cmd/list-app-federated-identity-credentials.go @@ -0,0 +1,116 @@ +// 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 cmd + +import ( + "context" + "os" + "os/signal" + "sync" + "time" + + "github.com/bloodhoundad/azurehound/v2/client" + "github.com/bloodhoundad/azurehound/v2/client/query" + "github.com/bloodhoundad/azurehound/v2/config" + "github.com/bloodhoundad/azurehound/v2/enums" + "github.com/bloodhoundad/azurehound/v2/models" + "github.com/bloodhoundad/azurehound/v2/panicrecovery" + "github.com/bloodhoundad/azurehound/v2/pipeline" + "github.com/spf13/cobra" +) + +func init() { + listRootCmd.AddCommand(listAppFICCmd) +} + +var listAppFICCmd = &cobra.Command{ + Use: "apps", + Long: "Lists Entra ID Application Federated Identity Credentials", + Run: listAppFICsCmdImpl, + SilenceUsage: true, +} + +func listAppFICsCmdImpl(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 app federated identity credentials...") + start := time.Now() + stream := listAppFICs(ctx, azClient, listApps(ctx, azClient)) + panicrecovery.HandleBubbledPanic(ctx, stop, log) + outputStream(ctx, stream) + duration := time.Since(start) + log.Info("collection completed", "duration", duration.String()) +} + +func listAppFICs(ctx context.Context, client client.AzureClient, apps <-chan azureWrapper[models.App]) <-chan azureWrapper[models.AppFICs] { + var ( + out = make(chan azureWrapper[models.AppFICs]) + streams = pipeline.Demux(ctx.Done(), apps, config.ColStreamCount.Value().(int)) + wg sync.WaitGroup + params = query.GraphParams{} + ) + + wg.Add(len(streams)) + for i := range streams { + stream := streams[i] + go func() { + defer panicrecovery.PanicRecovery() + defer wg.Done() + for app := range stream { + var ( + data = models.AppFICs{ + AppId: app.Data.AppId, + } + count = 0 + ) + for item := range client.ListAzureADAppFICs(ctx, app.Data.Id, params) { + if item.Error != nil { + log.Error(item.Error, "unable to continue processing owners for this app", "appId", app.Data.AppId) + } else { + appFIC := models.AppFIC{ + FIC: item.Ok, + AppId: app.Data.AppId, + } + log.V(2).Info("found app FIC", "appFic", appFIC) + count++ + data.FICs = append(data.FICs, appFIC) + } + } + + if ok := pipeline.Send(ctx.Done(), out, NewAzureWrapper( + enums.KindAZFederatedIdentityCredential, + data, + )); !ok { + return + } + log.V(1).Info("finished listing app fics", "appId", app.Data.AppId, "count", count) + } + }() + } + + go func() { + wg.Wait() + close(out) + log.Info("finished listing all app fics") + }() + + return out +} diff --git a/cmd/list-azure-ad.go b/cmd/list-azure-ad.go index f3307cf7..79380572 100644 --- a/cmd/list-azure-ad.go +++ b/cmd/list-azure-ad.go @@ -81,9 +81,10 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{ ) // Enumerate Apps, AppOwners and AppMembers - appChans := pipeline.TeeFixed(ctx.Done(), listApps(ctx, client), 2) + appChans := pipeline.TeeFixed(ctx.Done(), listApps(ctx, client), 3) apps := pipeline.ToAny(ctx.Done(), appChans[0]) appOwners := pipeline.ToAny(ctx.Done(), listAppOwners(ctx, client, appChans[1])) + appFICs := pipeline.ToAny(ctx.Done(), listAppFICs(ctx, client, appChans[2])) // Enumerate Devices and DeviceOwners pipeline.Tee(ctx.Done(), listDevices(ctx, client), devices, devices2) @@ -119,6 +120,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{ return pipeline.Mux(ctx.Done(), appOwners, + appFICs, appRoleAssignments, apps, deviceOwners, diff --git a/enums/kind.go b/enums/kind.go index db9c7993..79dbf0df 100644 --- a/enums/kind.go +++ b/enums/kind.go @@ -23,6 +23,7 @@ const ( KindAZApp Kind = "AZApp" KindAZAppMember Kind = "AZAppMember" KindAZAppOwner Kind = "AZAppOwner" + KindAZFederatedIdentityCredential Kind = "AZFederatedIdentityCredential" KindAZDevice Kind = "AZDevice" KindAZDeviceOwner Kind = "AZDeviceOwner" KindAZGroup Kind = "AZGroup" diff --git a/models/app-fic.go b/models/app-fic.go new file mode 100644 index 00000000..6c92e077 --- /dev/null +++ b/models/app-fic.go @@ -0,0 +1,44 @@ +// 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 models + +import ( + "encoding/json" +) + +type AppFIC struct { + FIC json.RawMessage `json:"fic"` + AppId string `json:"appId"` +} + +func (s *AppFIC) MarshalJSON() ([]byte, error) { + output := make(map[string]any) + output["appId"] = s.AppId + + if fic, err := OmitEmpty(s.FIC); err != nil { + return nil, err + } else { + output["fic"] = fic + return json.Marshal(output) + } +} + +type AppFICs struct { + FICs []AppFIC `json:"fics"` + AppId string `json:"appId"` +} From b043349a05b7e84c5ecb4c58b24ae14da57ad6b9 Mon Sep 17 00:00:00 2001 From: hotnops Date: Wed, 4 Feb 2026 10:19:34 -0800 Subject: [PATCH 2/6] Code rabbit fixes --- client/apps.go | 2 +- cmd/list-app-federated-identity-credentials.go | 2 +- models/app-fic.go | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/client/apps.go b/client/apps.go index e8df6b84..4527890d 100644 --- a/client/apps.go +++ b/client/apps.go @@ -63,7 +63,7 @@ func (s *azureClient) ListAzureADAppOwners(ctx context.Context, objectId string, func (s *azureClient) ListAzureADAppFICs(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage] { var ( out = make(chan AzureResult[json.RawMessage]) - path = fmt.Sprintf("/%s/applications/%s/federatedIdentityCredentials/", constants.GraphApiBetaVersion, objectId) + path = fmt.Sprintf("/%s/applications/%s/federatedIdentityCredentials", constants.GraphApiBetaVersion, objectId) ) if params.Top == 0 { diff --git a/cmd/list-app-federated-identity-credentials.go b/cmd/list-app-federated-identity-credentials.go index 679f0489..1db1364c 100644 --- a/cmd/list-app-federated-identity-credentials.go +++ b/cmd/list-app-federated-identity-credentials.go @@ -83,7 +83,7 @@ func listAppFICs(ctx context.Context, client client.AzureClient, apps <-chan azu ) for item := range client.ListAzureADAppFICs(ctx, app.Data.Id, params) { if item.Error != nil { - log.Error(item.Error, "unable to continue processing owners for this app", "appId", app.Data.AppId) + log.Error(item.Error, "unable to continue processing federated identity credentials for this app", "appId", app.Data.AppId) } else { appFIC := models.AppFIC{ FIC: item.Ok, diff --git a/models/app-fic.go b/models/app-fic.go index 6c92e077..dd5f034b 100644 --- a/models/app-fic.go +++ b/models/app-fic.go @@ -30,6 +30,10 @@ func (s *AppFIC) MarshalJSON() ([]byte, error) { output := make(map[string]any) output["appId"] = s.AppId + if s.FIC == nil { + return nil, nil + } + if fic, err := OmitEmpty(s.FIC); err != nil { return nil, err } else { From a45f5dcb4764856a8ea8d814de9bd38543f7ab8e Mon Sep 17 00:00:00 2001 From: hotnops Date: Thu, 12 Feb 2026 08:13:44 -0800 Subject: [PATCH 3/6] Only adding FIC if not nil --- cmd/list-app-federated-identity-credentials.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/list-app-federated-identity-credentials.go b/cmd/list-app-federated-identity-credentials.go index 1db1364c..1ab6ebda 100644 --- a/cmd/list-app-federated-identity-credentials.go +++ b/cmd/list-app-federated-identity-credentials.go @@ -84,7 +84,7 @@ func listAppFICs(ctx context.Context, client client.AzureClient, apps <-chan azu for item := range client.ListAzureADAppFICs(ctx, app.Data.Id, params) { if item.Error != nil { log.Error(item.Error, "unable to continue processing federated identity credentials for this app", "appId", app.Data.AppId) - } else { + } else if item.Ok != nil { appFIC := models.AppFIC{ FIC: item.Ok, AppId: app.Data.AppId, From 4b3002762329651c9751ad1e4a239fd319d0d5bc Mon Sep 17 00:00:00 2001 From: Daniel Heinsen Date: Thu, 12 Feb 2026 09:15:48 -0800 Subject: [PATCH 4/6] Update cmd/list-app-federated-identity-credentials.go Co-authored-by: Rohan Vazarkar --- cmd/list-app-federated-identity-credentials.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/list-app-federated-identity-credentials.go b/cmd/list-app-federated-identity-credentials.go index 1ab6ebda..2bc13983 100644 --- a/cmd/list-app-federated-identity-credentials.go +++ b/cmd/list-app-federated-identity-credentials.go @@ -39,7 +39,7 @@ func init() { } var listAppFICCmd = &cobra.Command{ - Use: "apps", + Use: "appfics", Long: "Lists Entra ID Application Federated Identity Credentials", Run: listAppFICsCmdImpl, SilenceUsage: true, From 506de5870add85d323fbb100137f752279d0d1c9 Mon Sep 17 00:00:00 2001 From: Daniel Heinsen Date: Wed, 18 Feb 2026 12:49:34 -0800 Subject: [PATCH 5/6] Update cmd/list-app-federated-identity-credentials.go Co-authored-by: Rohan Vazarkar --- cmd/list-app-federated-identity-credentials.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/list-app-federated-identity-credentials.go b/cmd/list-app-federated-identity-credentials.go index 2bc13983..6cb353f1 100644 --- a/cmd/list-app-federated-identity-credentials.go +++ b/cmd/list-app-federated-identity-credentials.go @@ -95,11 +95,13 @@ func listAppFICs(ctx context.Context, client client.AzureClient, apps <-chan azu } } - if ok := pipeline.Send(ctx.Done(), out, NewAzureWrapper( - enums.KindAZFederatedIdentityCredential, - data, - )); !ok { - return + if data.FICs != nil { + if ok := pipeline.Send(ctx.Done(), out, NewAzureWrapper( + enums.KindAZFederatedIdentityCredential, + data, + )); !ok { + return + } } log.V(1).Info("finished listing app fics", "appId", app.Data.AppId, "count", count) } From 0332e9a512c2afd5b1ed2c60c2e27af8e22fe89a Mon Sep 17 00:00:00 2001 From: Daniel Heinsen Date: Wed, 18 Feb 2026 12:49:42 -0800 Subject: [PATCH 6/6] Update cmd/list-app-federated-identity-credentials.go Co-authored-by: Rohan Vazarkar --- cmd/list-app-federated-identity-credentials.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/list-app-federated-identity-credentials.go b/cmd/list-app-federated-identity-credentials.go index 6cb353f1..5c93ed2b 100644 --- a/cmd/list-app-federated-identity-credentials.go +++ b/cmd/list-app-federated-identity-credentials.go @@ -84,7 +84,7 @@ func listAppFICs(ctx context.Context, client client.AzureClient, apps <-chan azu for item := range client.ListAzureADAppFICs(ctx, app.Data.Id, params) { if item.Error != nil { log.Error(item.Error, "unable to continue processing federated identity credentials for this app", "appId", app.Data.AppId) - } else if item.Ok != nil { + } else { appFIC := models.AppFIC{ FIC: item.Ok, AppId: app.Data.AppId,