From 6903b6ad7091d81c3e5c5cc4bc998e79a87cfe1b Mon Sep 17 00:00:00 2001 From: David Itkin Date: Fri, 28 Mar 2025 16:08:43 -0400 Subject: [PATCH] Added azure-deploy directory with scripts and templates to install container instance using managed identity authentication. --- azure-deploy/README.md | 83 +++++ azure-deploy/mainTemplate.bicep | 126 +++++++ azure-deploy/modules/containerIdentity.bicep | 28 ++ azure-deploy/modules/containerInstance.bicep | 77 ++++ azure-deploy/modules/graphPermissions.bicep | 124 +++++++ azure-deploy/scripts/create-container-umi.ps1 | 196 ++++++++++ .../create-deployment-umi-with-perms.ps1 | 221 ++++++++++++ .../scripts/single-script-full-deployment.ps1 | 338 ++++++++++++++++++ 8 files changed, 1193 insertions(+) create mode 100644 azure-deploy/README.md create mode 100644 azure-deploy/mainTemplate.bicep create mode 100644 azure-deploy/modules/containerIdentity.bicep create mode 100644 azure-deploy/modules/containerInstance.bicep create mode 100644 azure-deploy/modules/graphPermissions.bicep create mode 100644 azure-deploy/scripts/create-container-umi.ps1 create mode 100644 azure-deploy/scripts/create-deployment-umi-with-perms.ps1 create mode 100644 azure-deploy/scripts/single-script-full-deployment.ps1 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/mainTemplate.bicep b/azure-deploy/mainTemplate.bicep new file mode 100644 index 00000000..bf6ad03b --- /dev/null +++ b/azure-deploy/mainTemplate.bicep @@ -0,0 +1,126 @@ +// 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 = 'ghcr.io/daviditkin/azurehound:feature-managed-identity-auth-18' + +// 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 +} + +// Get reference to deployment identity +resource deploymentUMI 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: deploymentUMIName + scope: resourceGroup(deploymentUMIResourceGroupName) +} + +// 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 + ] +} + +// 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) // Changed back to resource group scope since we're deploying a deployment script + params: { + location: location + deploymentUMIId: deploymentUMI.id + deploymentUMIResourceGroupName: deploymentUMIResourceGroupName + deploymentUMIName: deploymentUMIName + 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 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..d8e0120b --- /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' + 'list' + ] + 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/graphPermissions.bicep b/azure-deploy/modules/graphPermissions.bicep new file mode 100644 index 00000000..9421a129 --- /dev/null +++ b/azure-deploy/modules/graphPermissions.bicep @@ -0,0 +1,124 @@ +// modules/graphPermissions.bicep +param location string +param deploymentUMIId 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)" +}