diff --git a/azure-deploy/README.md b/azure-deploy/README.md new file mode 100644 index 00000000..c4b0acc3 --- /dev/null +++ b/azure-deploy/README.md @@ -0,0 +1,83 @@ +# AzureHoundDeploy + +## Overview + +AzureHound now supports Managed Identity authentication. This allows AzureHound to be run in an Azure Container Instance. The Container Instance must be associated with a Managed Identity that has the required RBAC Roles and Graph Permissions that AzureHound requires. + +This repository containes the Azure Resource Manager (ARM) Template along with supporting scripts that +allow a user to conveniently deploy and configure AzureHound to an Azure Container Instance. Specifically this ARM template provides the following functionallity. + +1) Deploy an AzureHound Instance that runs in an Azure Container Instance +2) Creates or uses an existing Container Instance +3) Creates or uses an existing Managed Identity for the Container that provides Azure permissions +4) Provides a wizard that configures the AzureHound Instance + +## Process + + +## Prerequisites + +In order for this ARM Template to create the container's User Managed Identity, the ARM Template requires an existing User Managed Identity with the following permissions: + + - Application.ReadWrite.All + - Managed Identity Contributor + - User Access Administrator + +This repository contains a `create-deployment-umi-with-perms.ps1` script that can be used to create deployment's user managed identity. Alternatively you can create the User Managed Identity in the Azure portal. + +## AzureHound Required Permissions + +AzureHound requires the following Azure permissions + + - Directory.Read.All + - Reader + +The ARM Template will create the Container Instance along with a User Managed Identity that provides this permissions to the AzureHound Instance. + +## Supporting Scripts + +The ARM Template is designed to create the Container Instance along with a User Managed Identity that will provide AzureHound with all the permissions it needs to run. However, it + +- Create/Fix Managed Identity For The ARM Template + `create-deployment-umi-with-perms.ps1` +- Create/Fix Managed Identity For the Container + `create-container-umi.ps1` +- Full end to end script. + `single-script-full-deployment.ps1` + +## Notes About Approach +ManagedIdentities can be assigned permissions just like App Registration (Enterprise Applications), however you are assigning the permissions to +the managed identity's application object id. After creation of a Managed Identity it takes some amount of time before the application id is associated with the managed identity. Therefore we add retry logic. + +## Permissions DeploymentScript requires +The `managed-identity-permissions.sh` script will require +the following permissions to be assigned to a managed identity. + +# Issues to document + +## `single-script-full-deployment` + +- it requires tenant-id, but this can be retrieved with `(Get-AzTenant).Id` after logging in with `Connect-AzAccount` +- does it require an existing container registry. + - Maybe use `$registry = New-AzContainerRegistry -ResourceGroupName "myResourceGroup" -Name "mycontainerregistry" -EnableAdminUser -Sku Basic -Location EastUS` + - but permissions required may be a problem. + + **maybe create a separate script** + +## The docker image must be loaded into the container registry before hand. +To do this this is the following procedure: + + 1) Have docker installed and running + 2) An image registry in Azure must exist or be created + 3) Have the authentication information for the existing azure image registry + 4) At the command line authenticate with the azure registry + - + 5) pull the latest AzureHound image + `docker pull ghcr.io/bloodhoundad/azurehound:` Note: Use the tag 'latest' unless you want a specific version of azurehound. + 6) tag the image to associate it with an the azure registry + `docker tag ghcr.io/owner/repository:tag .azurecr.io/:tag Note: Best to use latest unless you need to maintain multiple versions in Azure's registry. + 7) docker push .azurecr.io/:tag + + + + diff --git a/azure-deploy/azurehound.parameters.json b/azure-deploy/azurehound.parameters.json new file mode 100644 index 00000000..3ae3d640 --- /dev/null +++ b/azure-deploy/azurehound.parameters.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "azureTenantId": { "value": "" }, + "azureAppID": {"value": ""}, + "azureSecret": {"value": ""}, + "bloodhoundInstanceDomain": { "value": "" }, + "bloodhoundTokenId": { "value": "" }, + "bloodhoundToken": { "value": "" } + } +} \ No newline at end of file diff --git a/azure-deploy/createUiDefinition.json b/azure-deploy/createUiDefinition.json new file mode 100644 index 00000000..e5ac0595 --- /dev/null +++ b/azure-deploy/createUiDefinition.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/0.1.2-preview/CreateUIDefinition.MultiVm.json#", + "handler": "Microsoft.Azure.CreateUIDef", + "version": "0.1.2-preview", + "parameters": { + "basics": [ + {} + ], + "steps": [ + { + "name": "azureHoundConfig", + "label": "AzureHound Config Params", + "elements": [ + { + "name": "azureTenantId", + "type": "Microsoft.Common.TextBox", + "label": "Azure Tenant ID", + "defaultValue": "", + "toolTip": "Azure Tenant ID", + "placeholder": "Azure Tenant ID", + "constraints": { + "required": true, + "regex": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "validationMessage": "Must be a valid GUID" + } + }, + { + "name": "azureAppID", + "type": "Microsoft.Common.TextBox", + "label": "Azure Application ID", + "defaultValue": "", + "toolTip": "Azure App ID", + "placeholder": "Azure App ID", + "constraints": { + "required": true, + "regex": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "validationMessage": "Must be a valid GUID" + } + }, + { + "name": "azureSecret", + "type": "Microsoft.Common.TextBox", + "label": "Azure Secret ID", + "defaultValue": "", + "toolTip": "Azure Secret ID", + "placeholder": "Azure Secret ID", + "constraints": { + "required": true, + "regex": "^.+$", + "validationMessage": "Token must be alphanumeric." + } + }, + { + "name": "bloodhoundInstanceDomain", + "type": "Microsoft.Common.TextBox", + "label": "BloodHound Instance Domain", + "defaultValue": "", + "toolTip": "Domain name of the BloodHound instance", + "placeholder": "Domain name of the BloodHound instance", + "constraints": { + "required": true, + "regex": "^([a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$", + "validationMessage": "Enter a valid domain (e.g., example.com)." + } + }, + { + "name": "bloodhoundTokenId", + "type": "Microsoft.Common.TextBox", + "label": "BloodHound Token ID", + "defaultValue": "", + "toolTip": "The BloodHound Token Id", + "placeholder": "The BloodHound Token Id", + "constraints": { + "required": true, + "regex": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "validationMessage": "Token ID must be a valid GUID." + } + }, + { + "name": "bloodhoundToken", + "type": "Microsoft.Common.TextBox", + "label": "BloodHound Token Secret", + "toolTip": "The secret Key of the BloodHound token", + "placeholder": "The BloodHound Token Key", + "constraints": { + "required": true, + "regex": "^.+$", + "validationMessage": "Token must be alphanumeric." + } + } + ] + } + ], + "outputs": { + "azureTenantId": "[steps('azureHoundConfig').azureTenantId]", + "azureAppID": "[steps('azureHoundConfig').azureAppID]", + "azureSecret": "[steps('azureHoundConfig').azureSecret]", + "bloodhoundInstanceDomain": "[steps('azureHoundConfig').bloodhoundInstanceDomain]", + "bloodhoundTokenId": "[steps('azureHoundConfig').bloodhoundTokenId]", + "bloodhoundToken": "[steps('azureHoundConfig').bloodhoundToken]" + } + } +} \ No newline at end of file diff --git a/azure-deploy/mainTemplate.bicep b/azure-deploy/mainTemplate.bicep new file mode 100644 index 00000000..d0f30ba3 --- /dev/null +++ b/azure-deploy/mainTemplate.bicep @@ -0,0 +1,142 @@ +// mainTemplate.bicep +targetScope = 'subscription' + +@description('Name of the resource group to deploy into') +param resourceGroupName string + +@description('Location for all resources') +param location string + +@description('Name of the container user managed identity') +param containerUMIName string + +@description('Resource group for the container UMI (defaults to main resource group if not specified)') +param containerUMIResourceGroupName string = resourceGroupName + +@description('Name of the deployment user managed identity') +param deploymentUMIName string + +@description('Resource group containing the deployment user managed identity') +param deploymentUMIResourceGroupName string + +@description('Azure tenant ID to analyze') +param azureTenantId string + +@description('Bloodhound instance domain') +param bloodhoundInstanceDomain string + +@description('Bloodhound token ID') +@secure() +param bloodhoundTokenId string + +@description('Bloodhound token') +@secure() +param bloodhoundToken string + +var containerName = '${resourceGroupName}-container-group' +var imageName = 'azurehounddeploy.azurecr.io/azurehound:latest' + +// Create main resource group if it doesn't exist +resource mainRG 'Microsoft.Resources/resourceGroups@2023-07-01' = { + name: resourceGroupName + location: location +} + +// Create container UMI resource group if different from main and doesn't exist +resource containerUMIRG 'Microsoft.Resources/resourceGroups@2023-07-01' = if (containerUMIResourceGroupName != resourceGroupName) { + name: containerUMIResourceGroupName + location: location +} + +// Create deployment UMI resource group if different from main +resource deploymentUMIRG 'Microsoft.Resources/resourceGroups@2023-07-01' = if (deploymentUMIResourceGroupName != resourceGroupName) { + name: deploymentUMIResourceGroupName + location: location +} + + +// Deploy or reference the container UMI +module containerIdentity './modules/containerIdentity.bicep' = { + name: 'containerIdentity-deployment' + scope: resourceGroup(containerUMIResourceGroupName) + params: { + location: location + containerUMIName: containerUMIName + } + dependsOn: [ + mainRG + containerUMIRG + ] +} + +// Call module to create deployment UMI in correct RG +module deploymentIdentity 'modules/deploymentIdentity.bicep' = { + name: 'deploymentIdentity-deployment' + scope: resourceGroup(deploymentUMIResourceGroupName) + params: { + location: location + deploymentUMIName: deploymentUMIName + } + dependsOn: [ + mainRG + deploymentUMIRG + ] +} + +// Assign Reader role at subscription scope +resource readerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, containerUMIName, 'Reader') + scope: subscription() + properties: { + principalId: containerIdentity.outputs.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7') // Reader role + principalType: 'ServicePrincipal' + } + dependsOn: [ + containerIdentity + ] +} + +// Deploy script to configure Graph API permissions +module graphPermissions './modules/graphPermissions.bicep' = { + name: 'graph-permissions-deployment' + scope: resourceGroup(resourceGroupName) + params: { + location: location + deploymentUMIName: deploymentUMIName + deploymentUMIResourceGroupName: deploymentUMIResourceGroupName + containerUMIPrincipalId: containerIdentity.outputs.principalId + } + dependsOn: [ + mainRG + containerIdentity + ] +} + +// Deploy the container instance +module containerInstance './modules/containerInstance.bicep' = { + name: 'container-instance-deployment' + scope: resourceGroup(resourceGroupName) + params: { + location: location + containerGroupName: containerName + containerUMIResourceId: containerIdentity.outputs.resourceId + imageName: imageName + bloodhoundInstanceDomain: bloodhoundInstanceDomain + azureTenantId: azureTenantId + bloodhoundTokenId: bloodhoundTokenId + bloodhoundToken: bloodhoundToken + } + dependsOn: [ + mainRG + graphPermissions + ] +} + +output containerUMIResourceId string = containerIdentity.outputs.resourceId +output containerUMIPrincipalId string = containerIdentity.outputs.principalId +output permissionSetupRequired bool = graphPermissions.outputs.needsManualSetup +output permissionStatus string = graphPermissions.outputs.statusMessage +output assignedPermissions array = graphPermissions.outputs.assignedPermissions +output deploymentUMIPrincipalId string = deploymentIdentity.outputs.principalId +output deploymentUMIId string = deploymentIdentity.outputs.resourceId \ No newline at end of file diff --git a/azure-deploy/mainTemplate.json b/azure-deploy/mainTemplate.json new file mode 100644 index 00000000..3aa1be08 --- /dev/null +++ b/azure-deploy/mainTemplate.json @@ -0,0 +1,506 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.11.1.770", + "templateHash": "4536988653306547200" + } + }, + "parameters": { + "azureTenantId": { + "type": "string", + "metadata": { + "description": "Azure tenant ID to analyze" + }, + "minLength": 36, + "maxLength": 36 + }, + "azureSecret": { + "type": "securestring", + "metadata": { + "description": "Azure Secret" + } + }, + "azureAppID": { + "type": "string", + "metadata": { + "description": "Azure App ID" + } + }, + "bloodhoundInstanceDomain": { + "type": "string", + "metadata": { + "description": "Bloodhound instance domain" + } + }, + "bloodhoundTokenId": { + "type": "string", + "metadata": { + "description": "Bloodhound token ID" + } + }, + "bloodhoundToken": { + "type": "securestring", + "metadata": { + "description": "Bloodhound token" + } + }, + "minReplica": { + "type": "int", + "defaultValue": 1, + "maxValue": 25, + "minValue": 0, + "metadata": { + "description": "Minimum number of replicas that will be deployed" + } + }, + "maxReplica": { + "type": "int", + "defaultValue": 3, + "maxValue": 25, + "minValue": 0, + "metadata": { + "description": "Maximum number of replicas that will be deployed" + } + } + }, + "variables": { + "acrLoginServer": "azurehoundregistry.azurecr.io", + "acrImageName": "[format('{0}/azurehound:latest', variables('acrLoginServer'))]", + "configcontent": "[concat('{\\\"batchsize\\\": 100,\\\"config\\\": \\\"/mnt/config/config.json\\\",\\\"instance\\\": \\\"https://', parameters('bloodhoundInstanceDomain'), '\\\",\\\"json\\\": false,\\\"managed-identity\\\": false,\\\"maxconnsperhost\\\": 20,\\\"maxidleconnsperhost\\\": 20,\\\"region\\\": \\\"cloud\\\",\\\"streamcount\\\": 25,\\\"tenant\\\": \\\"', parameters('azureTenantId'),'\\\",\\\"token\\\": \\\"', parameters('bloodhoundToken'),'\\\",\\\"tokenid\\\": \\\"', parameters('bloodhoundTokenId'),'\\\",\\\"secret\\\": \\\"', parameters('azureSecret'),'\\\",\\\"app\\\": \\\"', parameters('azureAppID'),'\\\",\\\"verbosity\\\": 2}')]", + "fileShareVolumeName": "config-volume", + "containerAppName": "[format('app-{0}', uniqueString(resourceGroup().id))]", + "containerAppEnvName": "[format('env-{0}', uniqueString(resourceGroup().id))]", + "containerAppLogAnalyticsName": "[format('containerapp-log-{0}', uniqueString(resourceGroup().id))]", + "location": "[resourceGroup().location]", + "containerUMIName": "[format('id-app-{0}', uniqueString(resourceGroup().id))]", + "functionAppName": "[format('fun-app-{0}', uniqueString(resourceGroup().id))]", + "hostingPlanName": "[format('hosting-plan-{0}', uniqueString(resourceGroup().id))]", + "applicationInsightsName": "[variables('functionAppName')]", + "storageAccountName": "[format('sa{0}', uniqueString(resourceGroup().id))]", + "functionPackageUri": "https://saazurehounddev.blob.core.windows.net/azurefunction/azurefunction.zip?sp=r&st=2025-07-24T07:57:27Z&se=2030-12-30T18:30:00Z&spr=https&sv=2024-11-04&sr=b&sig=f6EYF8ePXpCn%2Bua0jJ9GxCWdVx39tMSUTMR18qEJ%2FvM%3D", + "runtime": "python" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2021-06-01", + "name": "[variables('containerAppLogAnalyticsName')]", + "location": "[variables('location')]", + "properties": { + "sku": { + "name": "PerGB2018" + } + } + }, + { + "type": "Microsoft.App/managedEnvironments", + "apiVersion": "2022-06-01-preview", + "name": "[variables('containerAppEnvName')]", + "location": "[variables('location')]", + "sku": { + "name": "Consumption" + }, + "properties": { + "appLogsConfiguration": { + "destination": "log-analytics", + "logAnalyticsConfiguration": { + "customerId": "[reference(resourceId('Microsoft.OperationalInsights/workspaces', variables('containerAppLogAnalyticsName'))).customerId]", + "sharedKey": "[listKeys(resourceId('Microsoft.OperationalInsights/workspaces', variables('containerAppLogAnalyticsName')), '2021-06-01').primarySharedKey]" + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', variables('containerAppLogAnalyticsName'))]" + ] + }, + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2022-01-31-preview", + "name": "[variables('containerUMIName')]", + "location": "[variables('location')]" + }, + { + "type": "Microsoft.App/containerApps", + "apiVersion": "2022-06-01-preview", + "name": "[variables('containerAppName')]", + "location": "[variables('location')]", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[format('{0}', resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('containerUMIName')))]": {} + } + }, + "properties": { + "managedEnvironmentId": "[resourceId('Microsoft.App/managedEnvironments', variables('containerAppEnvName'))]", + "configuration": { + "ingress": { + "external": true, + "targetPort": 80, + "allowInsecure": false, + "traffic": [ + { + "latestRevision": true, + "weight": 100 + } + ] + } + }, + "template": { + "revisionSuffix": "revision25065", + "initContainers": [ + { + "name": "config-writer", + "image": "mcr.microsoft.com/azure-cli:latest", // A lightweight image with bash for scripting + "command": [ + "bash", + "-c" + ], + "args": [ + // Construct the JSON using environment variables populated from Container variable + "[concat('echo ', variables('configcontent'), ' > /mnt/config/config.json')]", + // Add a confirmation message to logs + "echo 'config.json created successfully in /mnt/config/'" + ], + "volumeMounts": [ + { + "mountPath": "/mnt/config", // Mount the volume here for the init container to write to + "volumeName": "[variables('fileShareVolumeName')]" + } + ] + } + ], + "containers": [ + { + "name": "[variables('containerAppName')]", + "image": "[variables('acrImageName')]", + "command": [ + "/azurehound", + "start" + ], + "args": [ + "-c", + "/mnt/config/config.json" + ], + "volumeMounts": [ + { + "volumeName": "[variables('fileShareVolumeName')]", + "mountPath": "/mnt/config" + } + ], + "resources": { + "cpu": "[json('.25')]", + "memory": ".5Gi" + } + } + ], + "volumes": [ + { + "name": "[variables('fileShareVolumeName')]", + "emptyDir": {} // An ephemeral volume that exists for the lifetime of the pod + } + ], + "scale": { + "minReplicas": "[parameters('minReplica')]", + "maxReplicas": "[parameters('maxReplica')]", + "rules": [ + { + "name": "http-requests", + "http": { + "metadata": { + "concurrentRequests": "10" + } + } + } + ] + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.App/managedEnvironments', variables('containerAppEnvName'))]", + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('containerUMIName'))]", + "[resourceId('Microsoft.Resources/deployments', 'graph-permissions-deployment')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "graph-permissions-deployment", + "resourceGroup": "[resourceGroup().name]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[variables('location')]" + }, + "deploymentUMIName": { + "value": "[variables('containerUMIName')]" + }, + "deploymentUMIResourceGroupName": { + "value": "[resourceGroup().name]" + }, + "containerUMIPrincipalId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'containerIdentity-deployment'), '2022-09-01').outputs.principalId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.36.1.42791", + "templateHash": "2556770267153897272" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "containerUMIPrincipalId": { + "type": "string" + }, + "deploymentUMIResourceGroupName": { + "type": "string" + }, + "deploymentUMIName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Resources/deploymentScripts", + "apiVersion": "2023-08-01", + "name": "graph-permissions-script", + "location": "[parameters('location')]", + "kind": "AzurePowerShell", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[format('{0}', extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, parameters('deploymentUMIResourceGroupName')), 'Microsoft.ManagedIdentity/userAssignedIdentities', parameters('deploymentUMIName')))]": {} + } + }, + "properties": { + "azPowerShellVersion": "9.7", + "retentionInterval": "P1D", + "timeout": "PT30M", + "cleanupPreference": "OnSuccess", + "scriptContent": " $ErrorActionPreference = \"Continue\"\n \n # Initialize arrays for tracking\n $warningsList = @()\n $successList = @()\n $needManualSetup = $false\n \n try {\n $token = (Get-AzAccessToken -ResourceUrl \"https://graph.microsoft.com/\").Token\n $graphAppId = \"00000003-0000-0000-c000-000000000000\"\n \n $graphSp = Get-AzADServicePrincipal -ApplicationId $graphAppId\n if (-not $graphSp) {\n $needManualSetup = $true\n }\n \n if ($graphSp) {\n $headers = @{\n 'Authorization' = \"Bearer $token\"\n 'Content-Type' = 'application/json'\n }\n\n try {\n $apiUrl = \"https://graph.microsoft.com/v1.0/servicePrincipals/$($env:ContainerUMIPrincipalId)/appRoleAssignments\"\n $existingAssignments = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Get\n \n $directoryReadAllId = \"7ab1d382-f21e-4acd-a863-ba3e13f7da61\"\n $existingAssignment = $existingAssignments.value | Where-Object { \n $_.appRoleId -eq $directoryReadAllId -and \n $_.resourceId -eq $graphSp.Id\n }\n\n if (-not $existingAssignment) {\n try {\n $body = @{\n principalId = $env:ContainerUMIPrincipalId\n resourceId = $graphSp.Id\n appRoleId = $directoryReadAllId\n } | ConvertTo-Json\n\n $result = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Post -Body $body\n $successList += \"Directory.Read.All\"\n }\n catch {\n $needManualSetup = $true\n }\n }\n else {\n $successList += \"Directory.Read.All\"\n }\n }\n catch {\n $needManualSetup = $true\n }\n }\n }\n catch {\n $needManualSetup = $true\n }\n\n # Create a structured permission status message\n $statusMessage = if ($needManualSetup) {\n @\"\nMANUAL PERMISSION SETUP REQUIRED\n------------------------------\nThe container's managed identity requires the following Microsoft Graph permission:\n- Directory.Read.All\n\nPlease run the provided setup script to configure these permissions:\n./setup-container-permissions.ps1 -PrincipalId $($env:ContainerUMIPrincipalId)\n\"@\n } else {\n \"All required permissions have been configured successfully.\"\n }\n \n # Output the results\n $DeploymentScriptOutputs = @{\n needsManualSetup = $needManualSetup\n statusMessage = $statusMessage\n assignedPermissions = $successList\n }\n ", + "environmentVariables": [ + { + "name": "ContainerUMIPrincipalId", + "value": "[parameters('containerUMIPrincipalId')]" + } + ] + } + } + ], + "outputs": { + "needsManualSetup": { + "type": "bool", + "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', 'graph-permissions-script'), '2023-08-01').outputs.needsManualSetup]" + }, + "statusMessage": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', 'graph-permissions-script'), '2023-08-01').outputs.statusMessage]" + }, + "assignedPermissions": { + "type": "array", + "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', 'graph-permissions-script'), '2023-08-01').outputs.assignedPermissions]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, resourceGroup().name), 'Microsoft.Resources/deployments', 'containerIdentity-deployment')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "containerIdentity-deployment", + "resourceGroup": "[resourceGroup().name]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[variables('location')]" + }, + "containerUMIName": { + "value": "[variables('containerUMIName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.36.1.42791", + "templateHash": "7723817674947761621" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "containerUMIName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "name": "[parameters('containerUMIName')]", + "location": "[parameters('location')]" + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(subscription().id, resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('containerUMIName')), 'Reader')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('containerUMIName')), '2023-01-31').principalId]", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", + "principalType": "ServicePrincipal" + }, + "dependsOn": [ + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('containerUMIName'))]" + ] + } + ], + "outputs": { + "resourceId": { + "type": "string", + "value": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('containerUMIName'))]" + }, + "principalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('containerUMIName')), '2023-01-31').principalId]" + } + } + } + } + }, + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2022-09-01", + "name": "[variables('storageAccountName')]", + "location": "[variables('location')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": {} + }, + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2022-03-01", + "name": "[variables('hostingPlanName')]", + "location": "[variables('location')]", + "sku": { + "name": "Y1", + "tier": "Dynamic" + }, + "kind": "linux", + "properties": { + "reserved": true + } + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[variables('applicationInsightsName')]", + "location": "[variables('location')]", + "tags": { + "[format('hidden-link:{0}', resourceId('Microsoft.Web/sites', variables('functionAppName')))]": "Resource" + }, + "properties": { + "Application_Type": "web" + }, + "kind": "web" + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2024-04-01", + "name": "[variables('functionAppName')]", + "location": "[variables('location')]", + "kind": "functionapp,linux", + "identity": { + "type": "SystemAssigned" + }, + "dependsOn": [ + "[resourceId('Microsoft.Insights/components', variables('applicationInsightsName'))]", + "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", + "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" + ], + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", + "reserved": true, + "siteConfig": { + "appSettings": [ + { + "name": "APPINSIGHTS_INSTRUMENTATIONKEY", + "value": "[reference(resourceId('Microsoft.Insights/components', variables('functionAppName')), '2020-02-02').InstrumentationKey]" + }, + { + "name": "AzureWebJobsStorage", + "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2022-09-01').keys[0].value, ';EndpointSuffix=core.windows.net')]" + }, + { + "name": "FUNCTIONS_EXTENSION_VERSION", + "value": "~4" + }, + { + "name": "WEBSITE_RUN_FROM_PACKAGE", + "value": "[variables('functionPackageUri')]" + }, + { + "name": "FUNCTIONS_WORKER_RUNTIME", + "value": "[variables('runtime')]" + }, + { + "name": "SUBSCRIPTION_ID", + "value": "[subscription().subscriptionId]" + }, + { + "name": "RESOURCE_GROUP_NAME", + "value": "[resourceGroup().name]" + }, + { + "name": "CONTAINER_APP_NAME", + "value": "[variables('containerAppName')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://portal.azure.com" + ] + }, + "linuxFxVersion": "Python|3.10", + "ftpsState":"Disabled", + "minTlsVersion":"1.2" + }, + "httpsOnly":true + } + } + ], + "outputs": { + "containerAppFQDN": { + "type": "string", + "value": "[reference(resourceId('Microsoft.App/containerApps', variables('containerAppName'))).configuration.ingress.fqdn]" + }, + "containerImage": { + "type": "string", + "value": "[variables('acrImageName')]" + } + } +} \ No newline at end of file diff --git a/azure-deploy/modules/containerIdentity.bicep b/azure-deploy/modules/containerIdentity.bicep new file mode 100644 index 00000000..54e6d110 --- /dev/null +++ b/azure-deploy/modules/containerIdentity.bicep @@ -0,0 +1,28 @@ +// modules/containerIdentity.bicep +param location string +param containerUMIName string + +// Reference existing identity if it exists, create if it doesn't +resource containerUMI 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: containerUMIName + location: location +} + +// Check if Reader role is already assigned +resource existingReaderRole 'Microsoft.Authorization/roleAssignments@2022-04-01' existing = { + scope: subscription() + name: guid(subscription().id, containerUMI.id, 'Reader') +} + +// Assign Reader role at subscription scope if not already assigned +resource readerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, containerUMI.id, 'Reader') + properties: { + principalId: containerUMI.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7') // Reader role + principalType: 'ServicePrincipal' + } +} + +output resourceId string = containerUMI.id +output principalId string = containerUMI.properties.principalId diff --git a/azure-deploy/modules/containerInstance.bicep b/azure-deploy/modules/containerInstance.bicep new file mode 100644 index 00000000..03579716 --- /dev/null +++ b/azure-deploy/modules/containerInstance.bicep @@ -0,0 +1,77 @@ +param location string +param containerGroupName string +param containerUMIResourceId string +param imageName string +param bloodhoundInstanceDomain string +param azureTenantId string + +@secure() +param bloodhoundTokenId string + +@secure() +param bloodhoundToken string + +var config = { + app: '' + auth: '' + batchsize: 100 + config: '/home/nonroot/.config/azurehound/config.json' + instance: 'https://${bloodhoundInstanceDomain}/' + json: false + 'managed-identity': true + maxconnsperhost: 20 + maxidleconnsperhost: 20 + region: 'cloud' + streamcount: 25 + tenant: azureTenantId + token: bloodhoundToken + tokenid: bloodhoundTokenId + verbosity: 0 +} + +resource containerGroup 'Microsoft.ContainerInstance/containerGroups@2023-05-01' = { + name: containerGroupName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${containerUMIResourceId}': {} + } + } + properties: { + containers: [ + { + name: 'azurehound' + properties: { + image: imageName + command: [ + '/azurehound' + 'start' + ] + volumeMounts: [ + { + name: 'config-volume' + mountPath: '/home/nonroot/.config/azurehound' + } + ] + resources: { + requests: { + cpu: '1' + memoryInGB: '1' + } + } + } + } + ] + volumes: [ + { + name: 'config-volume' + secret: { + 'config.json': base64(string(config)) + } + } + ] + osType: 'Linux' + restartPolicy: 'Never' + } +} diff --git a/azure-deploy/modules/deploymentIdentity.bicep b/azure-deploy/modules/deploymentIdentity.bicep new file mode 100644 index 00000000..e7e96cf8 --- /dev/null +++ b/azure-deploy/modules/deploymentIdentity.bicep @@ -0,0 +1,11 @@ +// modules/deploymentIdentity.bicep +param location string +param deploymentUMIName string + +resource deploymentUMI 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: deploymentUMIName + location: location +} + +output resourceId string = deploymentUMI.id +output principalId string = deploymentUMI.properties.principalId diff --git a/azure-deploy/modules/graphPermissions.bicep b/azure-deploy/modules/graphPermissions.bicep new file mode 100644 index 00000000..8a823bb3 --- /dev/null +++ b/azure-deploy/modules/graphPermissions.bicep @@ -0,0 +1,123 @@ +// modules/graphPermissions.bicep +param location string +param containerUMIPrincipalId string +param deploymentUMIResourceGroupName string +param deploymentUMIName string + +// Reference the deployment UMI to ensure it exists before using it +resource deploymentUMI 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: deploymentUMIName + scope: resourceGroup(deploymentUMIResourceGroupName) +} + +resource graphPermissionScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: 'graph-permissions-script' + location: location + kind: 'AzurePowerShell' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${deploymentUMI.id}': {} + } + } + properties: { + azPowerShellVersion: '9.7' + retentionInterval: 'P1D' + timeout: 'PT30M' + cleanupPreference: 'Always' + scriptContent: ''' + $ErrorActionPreference = "Continue" + + # Initialize arrays for tracking + $warningsList = @() + $successList = @() + $needManualSetup = $false + + try { + $token = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/").Token + $graphAppId = "00000003-0000-0000-c000-000000000000" + + $graphSp = Get-AzADServicePrincipal -ApplicationId $graphAppId + if (-not $graphSp) { + $needManualSetup = $true + } + + if ($graphSp) { + $headers = @{ + 'Authorization' = "Bearer $token" + 'Content-Type' = 'application/json' + } + + try { + $apiUrl = "https://graph.microsoft.com/v1.0/servicePrincipals/$($env:ContainerUMIPrincipalId)/appRoleAssignments" + $existingAssignments = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Get + + $directoryReadAllId = "7ab1d382-f21e-4acd-a863-ba3e13f7da61" + $existingAssignment = $existingAssignments.value | Where-Object { + $_.appRoleId -eq $directoryReadAllId -and + $_.resourceId -eq $graphSp.Id + } + + if (-not $existingAssignment) { + try { + $body = @{ + principalId = $env:ContainerUMIPrincipalId + resourceId = $graphSp.Id + appRoleId = $directoryReadAllId + } | ConvertTo-Json + + $result = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Post -Body $body + $successList += "Directory.Read.All" + } + catch { + $needManualSetup = $true + } + } + else { + $successList += "Directory.Read.All" + } + } + catch { + $needManualSetup = $true + } + } + } + catch { + $needManualSetup = $true + } + + # Create a structured permission status message + $statusMessage = if ($needManualSetup) { + @" +MANUAL PERMISSION SETUP REQUIRED +------------------------------ +The container's managed identity requires the following Microsoft Graph permission: +- Directory.Read.All + +Please run the provided setup script to configure these permissions: +./setup-container-permissions.ps1 -PrincipalId $($env:ContainerUMIPrincipalId) +"@ + } else { + "All required permissions have been configured successfully." + } + + # Output the results + $DeploymentScriptOutputs = @{ + needsManualSetup = $needManualSetup + statusMessage = $statusMessage + assignedPermissions = $successList + } + ''' + environmentVariables: [ + { + name: 'ContainerUMIPrincipalId' + value: containerUMIPrincipalId + } + ] + } +} + +// Output these values so they can be captured by the main template +output needsManualSetup bool = graphPermissionScript.properties.outputs.needsManualSetup +output statusMessage string = graphPermissionScript.properties.outputs.statusMessage +output assignedPermissions array = graphPermissionScript.properties.outputs.assignedPermissions diff --git a/azure-deploy/scripts/create-container-umi.ps1 b/azure-deploy/scripts/create-container-umi.ps1 new file mode 100644 index 00000000..27b58164 --- /dev/null +++ b/azure-deploy/scripts/create-container-umi.ps1 @@ -0,0 +1,196 @@ +param( + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$true)] + [string]$Location, + [Parameter(Mandatory=$true)] + [string]$IdentityName +) + +function New-ManagedIdentity { + param( + [Parameter(Mandatory=$true)] + [PSObject]$ResourceGroup, + [Parameter(Mandatory=$true)] + [string]$IdentityName + ) + + $identity = New-AzUserAssignedIdentity -ResourceGroupName $ResourceGroup.ResourceGroupName -Name $IdentityName -Location $ResourceGroup.Location + if (-not $identity) { + throw "Failed to create managed identity" + } + + # Wait for identity to propagate to AAD + Write-Host "Waiting for identity $IdentityName to propagate to Azure AD..." + $timeout = (Get-Date).AddMinutes(2) + $servicePrincipal = $null + + while ((Get-Date) -lt $timeout) { + $servicePrincipal = Get-AzADServicePrincipal -ObjectId $identity.PrincipalId -ErrorAction SilentlyContinue + if ($servicePrincipal) { + Write-Host "Identity propagation confirmed" + break + } + Write-Host "Waiting for identity propagation..." + Start-Sleep -Seconds 10 + } + + if (-not $servicePrincipal) { + throw "Timeout waiting for identity to propagate to Azure AD" + } + + return $identity +} + + +function Add-RoleAssignment { + param( + [Parameter(Mandatory=$true)] + [string]$PrincipalId, + [Parameter(Mandatory=$true)] + [string]$RoleDefinitionName, + [Parameter(Mandatory=$true)] + [string]$Scope + ) + + try { + New-AzRoleAssignment -ObjectId $PrincipalId ` + -RoleDefinitionName $RoleDefinitionName ` + -Scope $Scope + } catch { + Write-Host "Failed to add role assignment skipping" + } +} + +function Test-RoleAssignment { + param( + [string]$PrincipalId, + [string]$RoleDefinitionName, + [string]$Scope + ) + + $assignment = Get-AzRoleAssignment ` + -ObjectId $PrincipalId ` + -RoleDefinitionName $RoleDefinitionName ` + -Scope $Scope ` + -ErrorAction SilentlyContinue + + return $null -ne $assignment +} + +# +#Add-RoleAssignment ` +# -PrincipalId $containerUMI.PrincipalId ` +# -RoleDefinitionName "Reader" ` +# -Scope $subscriptionScope +# + +function Add-GraphApiPermissionWithPropogationTest { + param( + [Parameter(Mandatory=$true)] + [string]$PrincipalId, + [Parameter(Mandatory=$true)] + [string]$PermissionName, + [int]$TimeoutInMinutes = 2 + ) + + $token = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/").Token + $graphAppId = "00000003-0000-0000-c000-000000000000" + $graphSp = Get-AzADServicePrincipal -ApplicationId $graphAppId + + # Define permission IDs + $permissionIds = @{ + "Directory.Read.All" = "7ab1d382-f21e-4acd-a863-ba3e13f7da61" + "User.Read" = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" + } + + if (-not $permissionIds.ContainsKey($PermissionName)) { + throw "Unknown permission: $PermissionName. Supported permissions are: $($permissionIds.Keys -join ', ')" + } + + $headers = @{ + 'Authorization' = "Bearer $token" + 'Content-Type' = 'application/json' + } + + # Function to check if permission exists + function Test-PermissionAssignment { + $existingAssignments = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" -Headers $headers -Method Get + return $existingAssignments.value | Where-Object { $_.appRoleId -eq $permissionIds[$PermissionName] } + } + + # Check if permission is already assigned + $existingAssignment = Test-PermissionAssignment + if ($existingAssignment) { + Write-Host "Permission $PermissionName is already assigned" + return + } + + $body = @{ + principalId = $PrincipalId + resourceId = $graphSp.Id + appRoleId = $permissionIds[$PermissionName] + } | ConvertTo-Json + + try { + $apiUrl = "https://graph.microsoft.com/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" + $result = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Post -Body $body + Write-Host "Permission assignment initiated for $PermissionName" + + # Wait for permission to propagate + Write-Host "Waiting for permission $PermissionName to propagate..." + $timeout = (Get-Date).AddMinutes($TimeoutInMinutes) + $permissionConfirmed = $false + + while ((Get-Date) -lt $timeout) { + if (Test-PermissionAssignment) { + $permissionConfirmed = $true + Write-Host "Permission $PermissionName successfully propagated" + break + } + Write-Host "Waiting for permission to propagate..." + Start-Sleep -Seconds 10 + } + + if (-not $permissionConfirmed) { + throw "Timeout waiting for permission $PermissionName to propagate" + } + } + catch { + $errorMessage = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue + Write-Warning "Failed to assign Graph API permission: $PermissionName" + Write-Warning "Error: $($errorMessage.error.message)" + throw + } +} + +try { + # Create the managed identity + if (-not ($resourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue)) { + Write-Host "Creating resource group: $ResourceGroupName" + $resourceGroup = New-AzResourceGroup -Name $ResourceGroupName -Location $Location + } + + if (-not ($containerUMI = Get-AzUserAssignedIdentity -ResourceGroupName $resourceGroup.ResourceGroupName -Name $IdentityName -ErrorAction SilentlyContinue)){ + $containerUMI = New-ManagedIdentity -ResourceGroup $resourceGroup -IdentityName $IdentityName + } + + # Add permissions to the container UMI + $subscriptionScope = "/subscriptions/$((Get-AzContext).Subscription.Id)" + Add-RoleAssignment ` + -PrincipalId $containerUMI.PrincipalId ` + -RoleDefinitionName "Reader" ` + -Scope $subscriptionScope + + # Then add Graph API permission + Add-GraphApiPermissionWithPropogationTest -PrincipalId $containerUMI.PrincipalId -PermissionName "Directory.Read.All" + + # Output the identity details needed for deployment + Write-Host "`nSetup complete! Use these values in your deployment:" -ForegroundColor Green + Write-Host "Identity Resource ID: $($containerUMI.Id)" + Write-Host "Principal ID: $($containerUMI.PrincipalId)" +} catch { + Write-Error "Error: $($_.Exception.Message)" +} + + diff --git a/azure-deploy/scripts/create-deployment-umi-with-perms.ps1 b/azure-deploy/scripts/create-deployment-umi-with-perms.ps1 new file mode 100644 index 00000000..6e040dab --- /dev/null +++ b/azure-deploy/scripts/create-deployment-umi-with-perms.ps1 @@ -0,0 +1,221 @@ +param( + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$true)] + [string]$Location, + [Parameter(Mandatory=$true)] + [string]$IdentityName +) + +# Ensure we're connected to Azure +if (-not (Get-AzContext)) { + Write-Error "Not connected to Azure. Please run Connect-AzAccount first." + exit 1 +} + +function New-DeploymentManagedIdentity { + param( + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$true)] + [string]$Location, + [Parameter(Mandatory=$true)] + [string]$IdentityName + ) + + # Create Resource Group if it doesn't exist + if (-not (Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue)) { + Write-Host "Creating resource group: $ResourceGroupName" + New-AzResourceGroup -Name $ResourceGroupName -Location $Location + } + + # Create the managed identity + Write-Host "Creating managed identity: $IdentityName" + $identity = New-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name $IdentityName -Location $Location + + # Wait for identity to propagate to AAD + Write-Host "Waiting for identity to propagate to Azure AD..." + $timeout = (Get-Date).AddMinutes(2) + $servicePrincipal = $null + + while ((Get-Date) -lt $timeout) { + $servicePrincipal = Get-AzADServicePrincipal -ObjectId $identity.PrincipalId -ErrorAction SilentlyContinue + if ($servicePrincipal) { + Write-Host "Identity propagation confirmed" + break + } + Write-Host "Waiting for identity propagation..." + Start-Sleep -Seconds 10 + } + + if (-not $servicePrincipal) { + throw "Timeout waiting for identity to propagate to Azure AD" + } + + return $identity +} + +function Add-GraphPermissions { + param( + [Parameter(Mandatory=$true)] + [string]$PrincipalId, + [Parameter(Mandatory=$true)] + [hashtable]$RequiredPermissions + ) + + $token = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/").Token + $graphAppId = "00000003-0000-0000-c000-000000000000" + $graphSp = Get-AzADServicePrincipal -ApplicationId $graphAppId + + $assignedPermissions = @{} + + foreach($permission in $RequiredPermissions.Keys) { + $assignedPermissions[$permission] = $false + $permissionId = $RequiredPermissions[$permission] + + $headers = @{ + 'Authorization' = "Bearer $token" + 'Content-Type' = 'application/json' + } + + # Check if permission exists + $existingAssignments = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" -Headers $headers -Method Get + $existingAssignment = $existingAssignments.value | Where-Object { $_.appRoleId -eq $permissionId } + + if ($existingAssignment) { + Write-Host "$permission permission is already assigned" + $assignedPermissions[$permission] = $true + continue + } + + $body = @{ + principalId = $PrincipalId + resourceId = $graphSp.Id + appRoleId = $permissionId + } | ConvertTo-Json + + try { + $apiUrl = "https://graph.microsoft.com/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" + $result = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Post -Body $body + Write-Host "Successfully assigned $permission permission" + $assignedPermissions[$permission] = $true + } + catch { + Write-Error "Failed to assign $permission permission: $_" + continue + } + } + return $assignedPermissions +} + +function Add-SubscriptionRoleAssignments { + param( + [Parameter(Mandatory=$true)] + [string]$PrincipalId, + [Parameter(Mandatory=$true)] + [hashtable]$RequiredRoles, + [Parameter(Mandatory=$false)] + [string]$SubscriptionId = (Get-AzContext).Subscription.Id + ) + + $assignedRoles = @{} + + $subscriptionScope = "/subscriptions/$SubscriptionId" + + foreach ($role in $RequiredRoles.Keys) { + $assignedRoles[$role] = $false + $roleDefinitionId = $RequiredRoles[$role] + + # Check if role assignment exists + $existingAssignment = Get-AzRoleAssignment ` + -ObjectId $PrincipalId ` + -RoleDefinitionId $roleDefinitionId ` + -Scope $subscriptionScope ` + -ErrorAction SilentlyContinue + + if ($existingAssignment) { + Write-Host "$role is already assigned" + $assignedRoles[$role] = $true + continue + } + + try { + Write-Host "Assigning $role..." + $assigned = $false + $role_assignment = New-AzRoleAssignment ` + -ObjectId $PrincipalId ` + -RoleDefinitionId $roleDefinitionId ` + -Scope $subscriptionScope + + Write-Host "Did role assignment $($info)" + if (-not $role_assignment) { + Write-Host "Failed to assign role $role to $PrincipalId skipping this role assignment" + continue + } + + # Wait for role assignment to propagate + $timeout = (Get-Date).AddMinutes(2) + + while ((Get-Date) -lt $timeout -and -not $assigned) { + Write-Host "Waiting for $role assignment to propagate..." + Start-Sleep -Seconds 10 + + $assigned = Get-AzRoleAssignment ` + -ObjectId $PrincipalId ` + -RoleDefinitionId $roleDefinitionId ` + -Scope $subscriptionScope ` + -ErrorAction SilentlyContinue + } + + if ($assigned) { + Write-Host "$role assignment confirmed" + $assignedRoles[$role] = $true + } + } + catch { + Write-Error "Failed to assign $role. skipping this role assignment" + } + } + return $assignedRoles +} + +try { + # Create the deployment managed identity + $identity = New-DeploymentManagedIdentity -ResourceGroupName $ResourceGroupName -Location $Location -IdentityName $IdentityName + + $graphPermissionsRequired = @{ + "Application.ReadWrite.All" = "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9" + } + $graphPermissions = Add-GraphPermissions -PrincipalId $identity.PrincipalId -RequiredPermissions $graphPermissionsRequired + + $roleDefinitionsRequired = @{ + 'Managed Identity Contributor' = 'f1a07417-d97a-45cb-824c-7a7467783830' + 'User Access Administrator' = '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9' + } + $roles = Add-SubscriptionRoleAssignments -PrincipalId $identity.PrincipalId -RequiredRoles $roleDefinitionsRequired + + # Output the identity details needed for deployment + Write-Host "`nDeployment identity created! Use these values in your ARM template:" -ForegroundColor Green + Write-Host "Identity Resource ID: $($identity.Id)" + Write-Host "Principal ID: $($identity.PrincipalId)" + Write-Host "Client ID: $($identity.ClientId)" + + Write-Host "`nIMPORTANT: Ensure that all permissions have been assigned!" -ForegroundColor Yellow + Write-Host "Please have an administrator assign the following permissions to the managed identity:" + Write-Host "1. Azure RBAC Roles (at subscription scope):" + foreach ($role in $roleDefinitionsRequired.Keys) { + if (-not $roles[$role]) { + Write-Host "Have administrator assign $role $($roleDefinitionsRequired[$role])" + } + } + Write-Host "2. Graph API Permissions:" + foreach ($graphRole in $graphPermissionsRequired.Keys) { + if (-not $graphPermissions[$graphRole]) { + Write-Host "Have administrator assign $graphRole $($graphPermissionsRequired[$graphRole])" + } + } +} catch { + Write-Error "Error: $($_.Exception.Message)" + Write-Error "Stack Trace: $($_.ScriptStackTrace)" + throw +} \ No newline at end of file diff --git a/azure-deploy/scripts/single-script-full-deployment.ps1 b/azure-deploy/scripts/single-script-full-deployment.ps1 new file mode 100644 index 00000000..3fcf3bb7 --- /dev/null +++ b/azure-deploy/scripts/single-script-full-deployment.ps1 @@ -0,0 +1,338 @@ +param( + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$true)] + [string]$Location, + [Parameter(Mandatory=$true)] + [string]$ContainerUMIName, + [Parameter(Mandatory=$true)] + [string]$AzureTenantId, + [Parameter(Mandatory=$true)] + [string]$BloodhoundInstanceDomain, + [Parameter(Mandatory=$true)] + [string]$BloodhoundTokenId, + [Parameter(Mandatory=$true)] + [string]$BloodhoundToken, + [Parameter(Mandatory=$true)] + [string]$RegistryPassword +) + +# Ensure we're connected to Azure +if (-not (Get-AzContext)) { + Write-Error "Not connected to Azure. Please run Connect-AzAccount first." + exit 1 +} + +function New-ManagedIdentity { + param( + [Parameter(Mandatory=$true)] + [PSObject]$ResourceGroup, + [Parameter(Mandatory=$true)] + [string]$IdentityName + ) + + $identity = New-AzUserAssignedIdentity -ResourceGroupName $ResourceGroup.ResourceGroupName -Name $IdentityName -Location $ResourceGroup.Location + if (-not $identity) { + throw "Failed to create managed identity" + } + + # Wait for identity to propagate to AAD + Write-Host "Waiting for identity $IdentityName to propagate to Azure AD..." + $timeout = (Get-Date).AddMinutes(2) + $servicePrincipal = $null + + while ((Get-Date) -lt $timeout) { + $servicePrincipal = Get-AzADServicePrincipal -ObjectId $identity.PrincipalId -ErrorAction SilentlyContinue + if ($servicePrincipal) { + Write-Host "Identity propagation confirmed" + break + } + Write-Host "Waiting for identity propagation..." + Start-Sleep -Seconds 10 + } + + if (-not $servicePrincipal) { + throw "Timeout waiting for identity to propagate to Azure AD" + } + + return $identity +} + + +function Add-RoleAssignment { + param( + [Parameter(Mandatory=$true)] + [string]$PrincipalId, + [Parameter(Mandatory=$true)] + [string]$RoleDefinitionName, + [Parameter(Mandatory=$true)] + [string]$Scope + ) + + New-AzRoleAssignment -ObjectId $PrincipalId ` + -RoleDefinitionName $RoleDefinitionName ` + -Scope $Scope +} + +function Test-RoleAssignment { + param( + [string]$PrincipalId, + [string]$RoleDefinitionName, + [string]$Scope + ) + + $assignment = Get-AzRoleAssignment ` + -ObjectId $PrincipalId ` + -RoleDefinitionName $RoleDefinitionName ` + -Scope $Scope ` + -ErrorAction SilentlyContinue + + return $null -ne $assignment +} + +# +#Add-RoleAssignment ` +# -PrincipalId $containerUMI.PrincipalId ` +# -RoleDefinitionName "Reader" ` +# -Scope $subscriptionScope +# + +function Add-GraphApiPermissionWithPropogationTest { + param( + [Parameter(Mandatory=$true)] + [string]$PrincipalId, + [Parameter(Mandatory=$true)] + [string]$PermissionName, + [int]$TimeoutInMinutes = 2 + ) + + $token = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/").Token + $graphAppId = "00000003-0000-0000-c000-000000000000" + $graphSp = Get-AzADServicePrincipal -ApplicationId $graphAppId + + # Define permission IDs + $permissionIds = @{ + "Directory.Read.All" = "7ab1d382-f21e-4acd-a863-ba3e13f7da61" + "User.Read" = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" + } + + if (-not $permissionIds.ContainsKey($PermissionName)) { + throw "Unknown permission: $PermissionName. Supported permissions are: $($permissionIds.Keys -join ', ')" + } + + $headers = @{ + 'Authorization' = "Bearer $token" + 'Content-Type' = 'application/json' + } + + # Function to check if permission exists + function Test-PermissionAssignment { + $existingAssignments = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" -Headers $headers -Method Get + return $existingAssignments.value | Where-Object { $_.appRoleId -eq $permissionIds[$PermissionName] } + } + + # Check if permission is already assigned + $existingAssignment = Test-PermissionAssignment + if ($existingAssignment) { + Write-Host "Permission $PermissionName is already assigned" + return + } + + $body = @{ + principalId = $PrincipalId + resourceId = $graphSp.Id + appRoleId = $permissionIds[$PermissionName] + } | ConvertTo-Json + + try { + $apiUrl = "https://graph.microsoft.com/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments" + $result = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Post -Body $body + Write-Host "Permission assignment initiated for $PermissionName" + + # Wait for permission to propagate + Write-Host "Waiting for permission $PermissionName to propagate..." + $timeout = (Get-Date).AddMinutes($TimeoutInMinutes) + $permissionConfirmed = $false + + while ((Get-Date) -lt $timeout) { + if (Test-PermissionAssignment) { + $permissionConfirmed = $true + Write-Host "Permission $PermissionName successfully propagated" + break + } + Write-Host "Waiting for permission to propagate..." + Start-Sleep -Seconds 10 + } + + if (-not $permissionConfirmed) { + throw "Timeout waiting for permission $PermissionName to propagate" + } + } + catch { + $errorMessage = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue + Write-Warning "Failed to assign Graph API permission: $PermissionName" + Write-Warning "Error: $($errorMessage.error.message)" + throw + } +} + + +# Create User Assigned Managed Identity if it doesn't exist +function New-AzureHoundContainerGroup { + param( + [Parameter(Mandatory=$true)] + [PSObject]$ResourceGroup, + [Parameter(Mandatory=$true)] + [string]$ContainerGroupName, + [Parameter(Mandatory=$true)] + [string]$ContainerInstanceName, + [Parameter(Mandatory=$true)] + [PSObject]$ContainerRegistry, + [Parameter(Mandatory=$true)] + [string]$ContainerImage, + [Parameter(Mandatory=$true)] + [string[]]$ContainerEntrypoint, + [Parameter(Mandatory=$true)] + [string]$ConfigJson, + [Parameter(Mandatory=$true)] + [PSObject]$ContainerUMI + ) + + # Convert config.json to base64 + $configJsonBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($configJson)) + + # Create container instance + $containerInstance = New-AzContainerInstanceObject ` + -Name $ContainerGroupName ` + -Image $ContainerImage ` + -VolumeMount @( + @{ + Name = "config-volume" + MountPath = "/home/nonroot/.config/azurehound" + } + ) ` + -Command $ContainerEntrypoint + + + # Create container group + $containerGroup = New-AzContainerGroup ` + -ResourceGroupName $ResourceGroup.ResourceGroupName ` + -Name $ContainerGroupName ` + -Location $ResourceGroup.Location ` + -IdentityType "UserAssigned" ` + -IdentityUserAssignedIdentity @{ + $ContainerUMI.Id = @{} + } ` + -Volume @{ + Name = "config-volume" + Secret = @{ + "config.json" = $configJsonBase64 + } + } ` + -ImageRegistryCredential @( + @{ + Server = $ContainerRegistry.LoginServer + Username = "ditkinreg" + Password = $RegistryPassword + } + ) ` + -Container $containerInstance ` + -OsType Linux ` + -RestartPolicy Never ` + + + return $containerGroup +} + +try { + # Create Resource Group if it doesn't exist assign existing or new resource group to variable rg + if (-not ($resourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue)) { + Write-Host "Creating resource group: $ResourceGroupName" + $resourceGroup = New-AzResourceGroup -Name $ResourceGroupName -Location $Location + } else { + Write-Host "Resource group $ResourceGroupName already exists" + } + + # Identity name derived from the resource group name + if (-not $ContainerUMIName) { + $containerUMIName = "$ResourceGroupName-container-umi" + Write-Host "Container UMI name not provided, using default: $containerUMIName" + } else { + Write-Host "Using provided container UMI name: $ContainerUMIName" + $containerUMIName = $ContainerUMIName + } + $containerName = "$ResourceGroupName-container-group" + + # Get the container registry + $containerRegistryName = "ditkinreg" + $containerRegistryResourceGroup = "ditkin-test-registry" + + # TODO: This should be parameterized + $imageName = "ditkin-test-image:latest" + + # Authenticate with ContainerRegistry + Connect-AzContainerRegistry -Name $containerRegistryName + + # Authenticate with ContainerRegistry + $acr = Get-AzContainerRegistry -ResourceGroupName $containerRegistryResourceGroup -Name $containerRegistryName + + if (-not ($containerUMI = Get-AzUserAssignedIdentity -ResourceGroupName $resourceGroup.ResourceGroupName -Name $containerUMIName -ErrorAction SilentlyContinue)){ + Write-Host "Creating managed identity: $containerUMIName" + $containerUMI = New-ManagedIdentity -ResourceGroup $resourceGroup -IdentityName $containerUMIName + } else { + Write-Host "Managed identity $containerUMIName already exists" + } + + # json config for azurehound + $config = @{ + app = "appValue" + auth = "" + batchsize = 100 + config = "/home/nonroot/.config/azurehound/config.json" + instance = "https://${BloodhoundInstanceDomain}/" + json = $false + 'managed-identity' = $true + maxconnsperhost = 20 + maxidleconnsperhost = 20 + region = "cloud" + streamcount = 25 + tenant = "${AzureTenantId}" + token = "${BloodhoundToken}" + tokenid = "${BloodhoundTokenId}" + verbosity = 0 + } + + $configJson = $config | ConvertTo-Json + + if (-not ($containerGroup = Get-AzContainerGroup -ResourceGroupName $ResourceGroupName -Name $containerName -ErrorAction SilentlyContinue)) { + $containerGroup = New-AzureHoundContainerGroup ` + -ResourceGroup $resourceGroup ` + -ContainerGroupName $containerName ` + -ContainerInstanceName "azurehound" ` + -ContainerRegistry $acr ` + -ContainerImage "$($acr.LoginServer)/$($imageName)" ` + -ContainerEntrypoint @("sleep", "infinity") ` + -ConfigJson $configJson ` + -ContainerUMI $containerUMI + } + + # Add permissions to the container UMI + # TODO: I think the scope needs to be based on the tenant we are analyzing + $subscriptionScope = "/subscriptions/$((Get-AzContext).Subscription.Id)" + Add-RoleAssignment ` + -PrincipalId $containerUMI.PrincipalId ` + -RoleDefinitionName "Reader" ` + -Scope $subscriptionScope + + # Then add Graph API permission as before + Add-GraphApiPermissionWithPropogationTest -PrincipalId $containerUMI.PrincipalId -PermissionName "Directory.Read.All" + + # Output the identity details needed for deployment + Write-Host "`nSetup complete! Use these values in your deployment:" -ForegroundColor Green + Write-Host "Identity Resource ID: $($containerUMI.Id)" + Write-Host "Principal ID: $($containerUMI.PrincipalId)" + +} catch { + Write-Error "Error: $($_.Exception.Message)" + Write-Error "Stack Trace: $($_.ScriptStackTrace)" +} diff --git a/azure-deploy/viewDefinition.json b/azure-deploy/viewDefinition.json new file mode 100644 index 00000000..289ee0a1 --- /dev/null +++ b/azure-deploy/viewDefinition.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/viewdefinition/0.0.1-preview/ViewDefinition.json#", + "views": [ + { + "kind": "Overview", + "properties": { + "header": "🚀 AzureHound Application", + "description": "Follow these steps to configure your Azurehound integration:\n\n **Azure Tenant ID:** Enter your Azure Tenant ID.\n\n **Azure Application ID:** Register an application in Microsoft Entra ID. Grant it the following API permissions: Directory.Read.All, RoleManagement.Read.All, and provide admin consent.\n\n **Azure Secret ID:** Create a Client Secret for the registered app, and enter the secret value (not the ID).\n\n **BloodHound Instance Domain:** Enter your BloodHound instance domain name (exclude http:// or https://).\n\n **BloodHound Token ID:** Enter the Managed Client Token ID.\n\n **BloodHound Token Secret:** Enter the Managed Client Token Secret." + } + } + ] +}