diff --git a/client/apps.go b/client/apps.go
index 80ce4fa4..4527890d 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..5c93ed2b
--- /dev/null
+++ b/cmd/list-app-federated-identity-credentials.go
@@ -0,0 +1,118 @@
+// 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: "appfics",
+ 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 federated identity credentials 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 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)
+ }
+ }()
+ }
+
+ 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 22524a1d..be13b148 100644
--- a/cmd/list-azure-ad.go
+++ b/cmd/list-azure-ad.go
@@ -80,9 +80,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
pipeline.Tee(ctx.Done(), listDevices(ctx, client), devices)
@@ -117,6 +118,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{
return pipeline.Mux(ctx.Done(),
appOwners,
+ appFICs,
appRoleAssignments,
apps,
devices,
diff --git a/enums/kind.go b/enums/kind.go
index 2322f1ea..b7c2d3e3 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"
KindAZGroup Kind = "AZGroup"
KindAZGroupMember Kind = "AZGroupMember"
diff --git a/models/app-fic.go b/models/app-fic.go
new file mode 100644
index 00000000..dd5f034b
--- /dev/null
+++ b/models/app-fic.go
@@ -0,0 +1,48 @@
+// 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 s.FIC == nil {
+ return nil, nil
+ }
+
+ 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"`
+}