From 2f0cfd7b1f9adfca1dbb9f11bd69e77ad06a7e07 Mon Sep 17 00:00:00 2001 From: Bastien Faure Date: Thu, 29 Oct 2026 23:55:08 -0400 Subject: [PATCH] Added Azure automation command --- azure/automation.go | 164 +++++++++++++++++++++++++++++++++++++++ azure/automation_test.go | 37 +++++++++ cli/azure.go | 11 +++ globals/azure.go | 1 + internal/azure.go | 21 +++++ 5 files changed, 234 insertions(+) create mode 100644 azure/automation.go create mode 100644 azure/automation_test.go diff --git a/azure/automation.go b/azure/automation.go new file mode 100644 index 0000000..d1838e0 --- /dev/null +++ b/azure/automation.go @@ -0,0 +1,164 @@ +package azure + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/Azure/azure-sdk-for-go/profiles/latest/automation/mgmt/automation" + "github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions" + "github.com/BishopFox/cloudfox/globals" + "github.com/BishopFox/cloudfox/internal" + "github.com/aws/smithy-go/ptr" + "github.com/fatih/color" + "github.com/kyokomi/emoji" +) + +var ( + getAutomationAccounts = getAutomationAccountsOriginal + getAutomationAssets = getAutomationAssetsOriginal +) + +type automationAssets struct { + Runbooks []automation.Runbook `json:"runbooks"` + Credentials []automation.Credential `json:"credentials"` + Webhooks []automation.Webhook `json:"webhooks"` +} + +func AzAutomationCommand(tenantID, subscription, outputDirectory, version string, verbosity int, wrap, merged bool) error { + if tenantID != "" && subscription == "" && merged { + tenant := populateTenant(tenantID) + if ptr.ToString(tenant.ID) == "" { + return fmt.Errorf("could not find Azure tenant %q", tenantID) + } + subs := GetSubscriptionsPerTenantID(ptr.ToString(tenant.ID)) + var rows [][]string + var loot strings.Builder + for _, sub := range subs { + subRows, subLoot, warnings, err := getAutomationData(sub) + if err != nil { + return err + } + rows = append(rows, subRows...) + loot.WriteString(subLoot) + for _, warning := range warnings { + fmt.Printf("[%s] %s\n", color.CyanString(globals.AZ_AUTOMATION_MODULE_NAME), warning) + } + } + if len(subs) > 0 { + writeScopedAzureOutput(outputDirectory, subs[0], globals.AZ_AUTOMATION_MODULE_NAME, []string{"Automation Account", "Subscription Name", "Subscription ID", "Resource Group", "Runbooks", "Credentials", "Webhooks"}, rows, "automation-assets", loot.String(), verbosity, wrap, true) + } + return nil + } + return runAzureScopedModule(tenantID, subscription, merged, func(sub subscriptions.Subscription) error { + fmt.Printf("[%s][%s] Enumerating Automation Accounts for subscription %s (%s)\n", color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", version)), color.CyanString(globals.AZ_AUTOMATION_MODULE_NAME), ptr.ToString(sub.DisplayName), ptr.ToString(sub.SubscriptionID)) + rows, loot, warnings, err := getAutomationData(sub) + if err != nil { + return err + } + for _, warning := range warnings { + fmt.Printf("[%s] %s\n", color.CyanString(globals.AZ_AUTOMATION_MODULE_NAME), warning) + } + writeScopedAzureOutput(outputDirectory, sub, globals.AZ_AUTOMATION_MODULE_NAME, []string{"Automation Account", "Subscription Name", "Subscription ID", "Resource Group", "Runbooks", "Credentials", "Webhooks"}, rows, "automation-assets", loot, verbosity, wrap, false) + return nil + }) +} + +func getAutomationData(subscription subscriptions.Subscription) ([][]string, string, []error, error) { + subscriptionID := ptr.ToString(subscription.SubscriptionID) + accounts, err := getAutomationAccounts(subscriptionID) + if err != nil { + return nil, "", nil, fmt.Errorf("could not enumerate Automation Accounts for subscription %s: %w", subscriptionID, err) + } + var rows [][]string + var loot strings.Builder + var warnings []error + for _, account := range accounts { + name := ptr.ToString(account.Name) + rg := resourceGroupFromID(ptr.ToString(account.ID)) + assets, err := getAutomationAssets(subscriptionID, rg, name) + if err != nil { + warnings = append(warnings, fmt.Errorf("could not enumerate assets for Automation Account %s: %w", name, err)) + continue + } + rows = append(rows, []string{name, ptr.ToString(subscription.DisplayName), subscriptionID, rg, fmt.Sprintf("%d", len(assets.Runbooks)), fmt.Sprintf("%d", len(assets.Credentials)), fmt.Sprintf("%d", len(assets.Webhooks))}) + if len(assets.Runbooks)+len(assets.Credentials)+len(assets.Webhooks) > 0 { + encoded, marshalErr := json.MarshalIndent(automationAssetsForLoot(assets), "", " ") + if marshalErr == nil { + fmt.Fprintf(&loot, "===============================================================\nAutomation Account: %s\nResource Group: %s\n%s\n\n", name, rg, encoded) + } + } + } + sort.Slice(rows, func(i, j int) bool { return rows[i][0] < rows[j][0] }) + return rows, loot.String(), warnings, nil +} + +func automationAssetsForLoot(assets automationAssets) map[string]any { + runbooks := make([]map[string]any, 0, len(assets.Runbooks)) + for _, runbook := range assets.Runbooks { + runbooks = append(runbooks, map[string]any{"name": ptr.ToString(runbook.Name), "properties": runbook.RunbookProperties}) + } + credentials := make([]map[string]any, 0, len(assets.Credentials)) + for _, credential := range assets.Credentials { + credentials = append(credentials, map[string]any{"name": ptr.ToString(credential.Name), "properties": credential.CredentialProperties}) + } + webhooks := make([]map[string]any, 0, len(assets.Webhooks)) + for _, webhook := range assets.Webhooks { + webhooks = append(webhooks, map[string]any{"name": ptr.ToString(webhook.Name), "properties": webhook.WebhookProperties}) + } + return map[string]any{"runbooks": runbooks, "credentials": credentials, "webhooks": webhooks} +} + +func getAutomationAccountsOriginal(subscriptionID string) ([]automation.Account, error) { + accountsClient, _, _, _ := internal.GetAutomationClients(subscriptionID) + page, err := accountsClient.List(context.TODO()) + if err != nil { + return nil, err + } + var accounts []automation.Account + for page.NotDone() { + accounts = append(accounts, page.Values()...) + if err := page.NextWithContext(context.TODO()); err != nil { + return nil, err + } + } + return accounts, nil +} + +func getAutomationAssetsOriginal(subscriptionID, resourceGroup, accountName string) (automationAssets, error) { + _, runbooksClient, credentialsClient, webhooksClient := internal.GetAutomationClients(subscriptionID) + var assets automationAssets + runbooks, err := runbooksClient.ListByAutomationAccount(context.TODO(), resourceGroup, accountName) + if err != nil { + return assets, err + } + for runbooks.NotDone() { + assets.Runbooks = append(assets.Runbooks, runbooks.Values()...) + if err := runbooks.NextWithContext(context.TODO()); err != nil { + return assets, err + } + } + credentials, err := credentialsClient.ListByAutomationAccount(context.TODO(), resourceGroup, accountName) + if err != nil { + return assets, err + } + for credentials.NotDone() { + assets.Credentials = append(assets.Credentials, credentials.Values()...) + if err := credentials.NextWithContext(context.TODO()); err != nil { + return assets, err + } + } + webhooks, err := webhooksClient.ListByAutomationAccount(context.TODO(), resourceGroup, accountName, "") + if err != nil { + return assets, err + } + for webhooks.NotDone() { + assets.Webhooks = append(assets.Webhooks, webhooks.Values()...) + if err := webhooks.NextWithContext(context.TODO()); err != nil { + return assets, err + } + } + return assets, nil +} diff --git a/azure/automation_test.go b/azure/automation_test.go new file mode 100644 index 0000000..29f5d99 --- /dev/null +++ b/azure/automation_test.go @@ -0,0 +1,37 @@ +package azure + +import ( + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/profiles/latest/automation/mgmt/automation" + "github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions" + "github.com/aws/smithy-go/ptr" +) + +func TestGetAutomationDataCountsAndLoot(t *testing.T) { + originalAccounts, originalAssets := getAutomationAccounts, getAutomationAssets + t.Cleanup(func() { getAutomationAccounts, getAutomationAssets = originalAccounts, originalAssets }) + getAutomationAccounts = func(string) ([]automation.Account, error) { + return []automation.Account{{Name: ptr.String("operations"), ID: ptr.String("/subscriptions/sub/resourceGroups/ops/providers/Microsoft.Automation/automationAccounts/operations")}}, nil + } + getAutomationAssets = func(_, resourceGroup, account string) (automationAssets, error) { + if resourceGroup != "ops" || account != "operations" { + t.Fatalf("unexpected automation target %s/%s", resourceGroup, account) + } + return automationAssets{Runbooks: []automation.Runbook{{Name: ptr.String("daily")}, {Name: ptr.String("weekly")}}, Credentials: []automation.Credential{{Name: ptr.String("service-account")}}, Webhooks: []automation.Webhook{{Name: ptr.String("deploy")}}}, nil + } + sub := subscriptions.Subscription{SubscriptionID: ptr.String("sub"), DisplayName: ptr.String("Production")} + rows, loot, warnings, err := getAutomationData(sub) + if err != nil || len(warnings) != 0 { + t.Fatalf("unexpected errors: err=%v warnings=%v", err, warnings) + } + if len(rows) != 1 || rows[0][4] != "2" || rows[0][5] != "1" || rows[0][6] != "1" { + t.Fatalf("unexpected automation rows: %+v", rows) + } + for _, value := range []string{"daily", "service-account", "deploy"} { + if !strings.Contains(loot, value) { + t.Errorf("loot missing %q: %s", value, loot) + } + } +} diff --git a/cli/azure.go b/cli/azure.go index 70c7dd0..9fb6ed8 100644 --- a/cli/azure.go +++ b/cli/azure.go @@ -141,6 +141,16 @@ Enumerate container registries for a specific subscription: } }, } + AzAutomationCommand = &cobra.Command{ + Use: "automation", + Aliases: []string{"automations"}, + Short: "Enumerates Azure Automation Accounts and their assets", + Run: func(cmd *cobra.Command, args []string) { + if err := azure.AzAutomationCommand(AzTenantID, AzSubscription, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable); err != nil { + log.Fatal(err) + } + }, + } ) func init() { @@ -164,6 +174,7 @@ func init() { AzStorageCommand, AzACRCommand, AzFuncCommand, + AzAutomationCommand, AzInventoryCommand) } diff --git a/globals/azure.go b/globals/azure.go index 444ddca..4512181 100644 --- a/globals/azure.go +++ b/globals/azure.go @@ -23,6 +23,7 @@ const AZ_RBAC_MODULE_NAME = "rbac" const AZ_STORAGE_MODULE_NAME = "storage" const AZ_ACR_MODULE_NAME = "acr" const AZ_FUNC_MODULE_NAME = "func" +const AZ_AUTOMATION_MODULE_NAME = "automation" // Microsoft endpoints const AZ_RESOURCE_MANAGER_ENDPOINT = "https://management.azure.com/" diff --git a/internal/azure.go b/internal/azure.go index 9c540fb..e550fa9 100644 --- a/internal/azure.go +++ b/internal/azure.go @@ -5,6 +5,7 @@ import ( "log" "github.com/Azure/azure-sdk-for-go/profiles/latest/authorization/mgmt/authorization" + "github.com/Azure/azure-sdk-for-go/profiles/latest/automation/mgmt/automation" "github.com/Azure/azure-sdk-for-go/profiles/latest/compute/mgmt/compute" "github.com/Azure/azure-sdk-for-go/profiles/latest/network/mgmt/network" "github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/resources" @@ -171,6 +172,26 @@ func GetWebAppsClient(subscriptionID string) web.AppsClient { return client } +func GetAutomationClients(subscriptionID string) (automation.AccountClient, automation.RunbookClient, automation.CredentialClient, automation.WebhookClient) { + authorizer, err := getAuthorizer(globals.AZ_RESOURCE_MANAGER_ENDPOINT) + if err != nil { + log.Fatalf("failed to get automation clients: %s", err) + } + accounts := automation.NewAccountClient(subscriptionID) + runbooks := automation.NewRunbookClient(subscriptionID) + credentials := automation.NewCredentialClient(subscriptionID) + webhooks := automation.NewWebhookClient(subscriptionID) + accounts.Authorizer = authorizer + runbooks.Authorizer = authorizer + credentials.Authorizer = authorizer + webhooks.Authorizer = authorizer + accounts.AddToUserAgent(globals.CLOUDFOX_USER_AGENT) + runbooks.AddToUserAgent(globals.CLOUDFOX_USER_AGENT) + credentials.AddToUserAgent(globals.CLOUDFOX_USER_AGENT) + webhooks.AddToUserAgent(globals.CLOUDFOX_USER_AGENT) + return accounts, runbooks, credentials, webhooks +} + func GetStorageAccountBlobClient(tenantID, storageAccountName string) (*azblob.Client, error) { serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", storageAccountName) cred, err := azidentity.NewAzureCLICredential(&azidentity.AzureCLICredentialOptions{TenantID: tenantID})