Skip to content
15 changes: 15 additions & 0 deletions client/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
14 changes: 14 additions & 0 deletions client/mocks/client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 118 additions & 0 deletions cmd/list-app-federated-identity-credentials.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

package cmd

import (
"context"
"os"
"os/signal"
"sync"
"time"
Comment on lines +20 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Go os.Kill SIGKILL cannot be trapped caught signal.NotifyContext

💡 Result:

On Unix-like systems, SIGKILL (and SIGSTOP) cannot be caught, blocked, or ignored, so Go cannot “trap” it—neither signal.Notify nor signal.NotifyContext will ever deliver SIGKILL to your program for graceful shutdown. The Go os/signal docs state this explicitly. [1] (This is an OS/POSIX rule, not a Go limitation. [2])

Also, in Go os.Kill is SIGKILL (and (*os.Process).Kill() “causes the Process to exit immediately”), so anything that sends os.Kill/Process.Kill() is inherently non-graceful. [3]

What to do instead (graceful shutdown)

Use a catchable signal like SIGINT/SIGTERM:

ctx, stop := signal.NotifyContext(context.Background(),
    os.Interrupt, syscall.SIGTERM,
)
defer stop()

<-ctx.Done() // start shutdown/cleanup

Then, if you need a “hard stop” fallback, do it from outside the process (service manager / supervisor): send SIGTERM, wait a grace period, then send SIGKILL.

Sources: Go os/signal docs on SIGKILL/SIGSTOP [1], POSIX signal rules (SIGKILL can’t be caught/ignored) [2], Go os docs showing Kill = SIGKILL / immediate exit semantics [3].

[1] https://pkg.go.dev/os/signal
[2] https://manual.cs50.io/7/signal
[3] https://pkg.go.dev/github.com/s1s1ty/go/src/os


🌐 Web query:

Go graceful shutdown SIGTERM syscall signal handling

💡 Result:

In Go, you typically implement “graceful shutdown” by catching SIGTERM/SIGINT and then cancelling work + calling (*http.Server).Shutdown(ctx) with a timeout.

Why this works

  • By default, SIGINT/SIGTERM terminate the program, but signal.Notify / signal.NotifyContext disable the default behavior and deliver the signal to your code so you can clean up. [1]
  • http.Server.Shutdown stops accepting new connections, closes listeners/idle conns, and waits for in-flight requests until the context expires. (It does not handle hijacked conns like WebSockets automatically.) [2]

Minimal pattern (recommended): signal.NotifyContext

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	srv := &http.Server{Addr: ":8080"}

	// Cancel this context when SIGINT (Ctrl+C) or SIGTERM is received.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen: %v", err)
		}
	}()

	<-ctx.Done() // wait for signal

	// Give outstanding requests time to finish.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("shutdown: %v", err)
	}
}

Notes

  • In containers/orchestrators (Docker/systemd/Kubernetes), SIGTERM is the common “please stop” signal, so include it. [3]
  • You can’t gracefully handle SIGKILL (it cannot be caught/ignored); only SIGTERM (then possibly a later SIGKILL if you don’t exit in time).

Sources:
[1] Go os/signal docs (signal.Notify behavior, incl. SIGTERM) https://go.dev/src/os/signal/doc.go
[2] Example + http.Server.Shutdown behavior notes (incl. WebSockets/hijacked conns) https://rafallorenz.com/go/handle-signals-to-graceful-shutdown-http-server/
[3] Typical use of SIGTERM for shutdown (e.g., Docker/systemd/K8s) https://m.php.cn/en/faq/1796844688.html


🏁 Script executed:

# Check the actual implementation at the mentioned lines
head -60 cmd/list-app-federated-identity-credentials.go | tail -45

Repository: SpecterOps/AzureHound

Length of output: 1403


Replace os.Kill with syscall.SIGTERM for proper graceful shutdown in containerized environments.

os.Kill (SIGKILL) cannot be trapped—the OS prevents it. Without SIGTERM, graceful shutdown fails when orchestrators (Docker, Kubernetes, systemd) send the standard termination signal. Only os.Interrupt (SIGINT) will trigger cleanup, which won't occur during normal container shutdown.

Proposed fix
 import (
 	"context"
 	"os"
 	"os/signal"
 	"sync"
+	"syscall"
 	"time"
-	ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, os.Kill)
+	ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)

Also applies to: 48-50

🤖 Prompt for AI Agents
In `@cmd/list-app-federated-identity-credentials.go` around lines 20 - 25, The
code registers signals using os.Kill which is uncatchable; update the signal
handling to use syscall.SIGTERM instead of os.Kill so graceful shutdown works in
containers: replace any use of os.Kill passed to signal.Notify (e.g.,
signal.Notify(sigCh, os.Interrupt, os.Kill)) with syscall.SIGTERM (e.g.,
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)) and add the syscall import;
ensure the same change is applied to the other occurrence around lines 48-50
referencing signal.Notify or os.Kill.


"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
}
4 changes: 3 additions & 1 deletion cmd/list-azure-ad.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -117,6 +118,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{

return pipeline.Mux(ctx.Done(),
appOwners,
appFICs,
appRoleAssignments,
apps,
devices,
Expand Down
1 change: 1 addition & 0 deletions enums/kind.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions models/app-fic.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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)
}
Comment on lines 29 to 42
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard empty FIC to avoid marshal errors.

OmitEmpty will error on empty/nil json.RawMessage. If FIC is optional, skip the call when empty and only include the field when present.

🛠️ Proposed fix
 func (s *AppFIC) MarshalJSON() ([]byte, error) {
 	output := make(map[string]any)
 	output["appId"] = s.AppId
 
+	if len(s.FIC) == 0 {
+		return json.Marshal(output)
+	}
+
 	if fic, err := OmitEmpty(s.FIC); err != nil {
 		return nil, err
 	} else {
 		output["fic"] = fic
 		return json.Marshal(output)
 	}
 }
📝 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
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)
}
func (s *AppFIC) MarshalJSON() ([]byte, error) {
output := make(map[string]any)
output["appId"] = s.AppId
if len(s.FIC) == 0 {
return json.Marshal(output)
}
if fic, err := OmitEmpty(s.FIC); err != nil {
return nil, err
} else {
output["fic"] = fic
return json.Marshal(output)
}
}
🤖 Prompt for AI Agents
In `@models/app-fic.go` around lines 29 - 38, The custom MarshalJSON in AppFIC
calls OmitEmpty on s.FIC which errors on nil/empty json.RawMessage; update
AppFIC.MarshalJSON to first check s.FIC (e.g., len(s.FIC) == 0 or s.FIC == nil)
and if empty skip calling OmitEmpty and do not set "fic" in the output map,
otherwise call OmitEmpty on s.FIC and set output["fic"] from its result before
json.Marshal(output); keep the rest of the method (setting "appId" and returning
json.Marshal(output)) unchanged.

}

type AppFICs struct {
FICs []AppFIC `json:"fics"`
AppId string `json:"appId"`
}
Loading