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"` +}