├── Bicep ├── main.bicep └── sentinel.bicep ├── LICENSE ├── .github └── workflows │ └── sentinel-deploy.yml ├── README.md ├── azure-pipelines.yml └── Scripts ├── README.md └── Set-SentinelContent.ps1 /Bicep/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | param rgLocation string 4 | param rgName string 5 | param dailyQuota int 6 | param lawName string 7 | 8 | // Deploy resource group 9 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 10 | name: rgName 11 | location: rgLocation 12 | } 13 | 14 | // Deploy Sentinel 15 | module sentinel 'sentinel.bicep' = { 16 | scope: rg 17 | name: 'sentinelDeployment' 18 | params: { 19 | dailyQuota: dailyQuota 20 | lawName: lawName 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 noodlemctwoodle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/sentinel-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Sentinel-As-Code 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: # Allows manual triggering 7 | 8 | jobs: 9 | EnableSentinelContentHub: 10 | name: Enable Sentinel Solutions, Configure Analytical Rules and Deploy Workbooks 11 | permissions: 12 | contents: write 13 | id-token: write 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Login to Azure 21 | continue-on-error: true 22 | id: login1 23 | uses: azure/login@v2 24 | with: 25 | client-id: ${{ secrets.SENTINEL_CLIENTID }} 26 | tenant-id: ${{ secrets.SENTINEL_TENANTID}} 27 | subscription-id: ${{ secrets.SENTINEL_SUBSCRIPTIONID }} 28 | environment: 'AzureCloud' 29 | audience: api://AzureADTokenExchange 30 | enable-AzPSSession: true 31 | 32 | - name: Enable Sentinel Solutions and Alert Rules 33 | uses: azure/powershell@v1 34 | continue-on-error: true 35 | with: 36 | inlineScript: | 37 | & "${{ github.workspace }}/Scripts/Set-SentinelContent.ps1" ` 38 | -ResourceGroup '${{ vars.RESOURCE_GROUP }}' ` 39 | -Workspace '${{ vars.WORKSPACE_NAME }}' ` 40 | -Region '${{ vars.REGION }}' ` 41 | -Solutions ${{ vars.SENTINEL_SOLUTIONS }} ` 42 | -SeveritiesToInclude ${{ vars.AR_SEVERITIES }} ` 43 | -IsGov 'false' 44 | azPSVersion: 'latest' 45 | -------------------------------------------------------------------------------- /Bicep/sentinel.bicep: -------------------------------------------------------------------------------- 1 | // Deploy Log Analytics workspace 2 | param dailyQuota int 3 | param lawName string 4 | 5 | // Create Log Analytics workspace 6 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { 7 | name: lawName 8 | location: resourceGroup().location 9 | properties: { 10 | sku: { 11 | name: 'PerGB2018' 12 | } 13 | retentionInDays: 90 14 | workspaceCapping: { 15 | dailyQuotaGb: (dailyQuota == 0) ? null : dailyQuota 16 | } 17 | } 18 | } 19 | 20 | // Enable Microsoft Sentinel 21 | resource Sentinel 'Microsoft.SecurityInsights/onboardingStates@2024-09-01' = { 22 | name: 'default' 23 | scope: logAnalyticsWorkspace 24 | } 25 | 26 | // Enable the Entity Behavior directory service 27 | resource EntityAnalytics 'Microsoft.SecurityInsights/settings@2023-02-01-preview' = { 28 | name: 'EntityAnalytics' 29 | kind: 'EntityAnalytics' 30 | scope: logAnalyticsWorkspace 31 | properties: { 32 | entityProviders: ['AzureActiveDirectory'] 33 | } 34 | dependsOn: [ 35 | Sentinel 36 | ] 37 | } 38 | 39 | // Enable the additional UEBA data sources 40 | resource uebaAnalytics 'Microsoft.SecurityInsights/settings@2023-02-01-preview' = { 41 | name: 'Ueba' 42 | kind: 'Ueba' 43 | scope: logAnalyticsWorkspace 44 | properties: { 45 | dataSources: ['AuditLogs', 'AzureActivity', 'SigninLogs', 'SecurityEvent'] 46 | } 47 | dependsOn: [ 48 | EntityAnalytics 49 | ] 50 | } 51 | 52 | // Output the Log Analytics workspace object 53 | output logAnalyticsWorkspace object = { 54 | name: logAnalyticsWorkspace.name 55 | id: logAnalyticsWorkspace.id 56 | location: logAnalyticsWorkspace.location 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sentinel-Deployment-CI 2 | 3 | ## Overview 4 | 5 | This repository provides a complete CI/CD solution for deploying Microsoft Sentinel environments using Azure DevOps pipelines. It combines infrastructure-as-code (Bicep) for resource provisioning with PowerShell automation for deploying Sentinel solutions, analytics rules, and workbooks. 6 | 7 | ## Repository Structure 8 | 9 | ``` 10 | ├── Bicep/ # Bicep templates for infrastructure 11 | │ ├── main.bicep # Main deployment template 12 | │ └── sentinel.bicep # Sentinel-specific resources 13 | ├── Scripts/ # PowerShell automation scripts 14 | │ ├── README.md # Documentation for Set-SentinelContent.ps1 15 | │ └── Set-SentinelContent.ps1 # Sentinel content deployment script 16 | ├── README.md # This file 17 | └── azure-pipelines.yml # Azure DevOps pipeline definition 18 | ``` 19 | 20 | ## Features 21 | 22 | - **Complete Sentinel Deployment**: Automate end-to-end deployment from infrastructure to content 23 | - **Infrastructure as Code**: Bicep templates for consistent infrastructure provisioning 24 | - **Content Automation**: PowerShell scripts for deploying Sentinel solutions, rules, and workbooks 25 | - **Resource Verification**: Checks for existing resources to prevent duplicate deployments 26 | - **CI/CD Integration**: Ready-to-use Azure DevOps pipeline configuration 27 | 28 | ## Pipeline Workflow 29 | 30 | The pipeline consists of three main stages: 31 | 32 | 1. **Check Existing Resources**: Verifies if Sentinel resources already exist in the target environment 33 | 2. **Deploy Bicep**: Provisions infrastructure (skipped if resources already exist) 34 | 3. **Enable Sentinel Content**: Deploys solutions, analytics rules, and workbooks 35 | 36 | ## Pipeline Variables 37 | 38 | | Variable Name | Description | 39 | |---------------|-------------| 40 | | `resourceGroup` | Azure Resource Group name | 41 | | `workspaceName` | Log Analytics workspace name | 42 | | `region` | Azure region (e.g., uksouth) | 43 | | `dailyQuota` | Daily data ingestion quota in GB | 44 | | `sentinelSolutions` | Comma-separated list of Sentinel solutions to deploy | 45 | | `arSeverities` | Severity levels for analytics rules (High, Medium, Low, Informational) | 46 | 47 | ## Setup Instructions 48 | 49 | ### Prerequisites 50 | 51 | - Azure subscription 52 | - Azure DevOps organization and project 53 | - Service Principal with contributor permissions 54 | 55 | ### Subscription Resource Providers 56 | 57 | `Required Subscription Resource Providers` 58 | 59 | To deploy this solution, you must enable the following Resource Providers in your subscription: 60 | 61 | - Microsoft.OperationsManagement 62 | - Microsoft.SecurityInsights 63 | 64 | ### Configuration Steps 65 | 66 | 1. **Import Repository** 67 | - Clone or import this repository into your Azure DevOps project 68 | 69 | 2. **Configure Pipeline Variables** 70 | - Create a pipeline with the following variables: 71 | ``` 72 | resourceGroup: "YourResourceGroupName" 73 | workspaceName: "YourWorkspaceName" 74 | region: "YourAzureRegion" 75 | dailyQuota: "10" 76 | sentinelSolutions: "Azure Activity","Microsoft 365","Threat Intelligence" 77 | arSeverities: "High","Medium","Low" 78 | ``` 79 | 80 | 3. **Set Up Service Connection** 81 | - Create an Azure service connection named "DevelopmentDeployments" 82 | - Or update the `azureSubscription` variable in the pipeline YAML 83 | 84 | 4. **Run the Pipeline** 85 | - The pipeline will automatically: 86 | - Check for existing resources 87 | - Deploy infrastructure if needed 88 | - Deploy Sentinel solutions and content 89 | 90 | ## Sentinel Content Deployment Script 91 | 92 | The `Set-SentinelContent.ps1` script handles the deployment of Microsoft Sentinel content including solutions, analytics rules, and workbooks. For detailed information about the script's capabilities, parameters, and examples, refer to the [script README](./Scripts/README.md). 93 | 94 | ## Contributing 95 | 96 | Contributions are welcome! Please follow these steps: 97 | 98 | 1. Fork the repository 99 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 100 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 101 | 4. Push to the branch (`git push origin feature/amazing-feature`) 102 | 5. Open a Pull Request 103 | 104 | ## Support the Project 105 | 106 | If you've found Sentinel-As-Code useful, consider buying me a coffee! Your support helps maintain this project and develop new features. 107 | 108 | Buy Me A Coffee 109 | 110 | While donations are appreciated, they're entirely optional. The best way to contribute is by submitting issues, suggesting improvements, or contributing code! 111 | Note: All donations will be reinvested into development time and improving this project. 112 | 113 | ## License 114 | 115 | This project is licensed under the MIT License. 116 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | - main 3 | 4 | pool: 5 | vmImage: 'ubuntu-latest' 6 | 7 | variables: 8 | azureSubscription: 'DeploymentConnection' 9 | 10 | stages: 11 | # ================================================================================= 12 | # Stage: CheckExistingResources 13 | # This stage checks whether Microsoft Sentinel resources already exist. 14 | # If resources are found, the Bicep deployment will be skipped in the next stage. 15 | # ================================================================================= 16 | - stage: CheckExistingResources 17 | displayName: 'Check if Sentinel Resources Exist' 18 | jobs: 19 | - job: CheckResources 20 | displayName: 'Verify Existing Sentinel Resources' 21 | steps: 22 | - task: AzurePowerShell@5 23 | displayName: 'Check for Existing Resources in Resource Group' 24 | name: CheckSentinelResources 25 | inputs: 26 | azureSubscription: $(azureSubscription) 27 | ScriptType: 'InlineScript' 28 | pwsh: true 29 | azurePowerShellVersion: LatestVersion 30 | Inline: | 31 | Write-Output "Checking if Sentinel resources already exist in Resource Group: $(RESOURCEGROUP)..." 32 | 33 | # Define variables 34 | $resourceGroupName = "$(RESOURCEGROUP)" 35 | $workspaceName = "$(WORKSPACENAME)" 36 | $resourcesExist = "false" 37 | 38 | # Check if the Log Analytics Workspace exists 39 | $law = Get-AzOperationalInsightsWorkspace -ResourceGroupName $resourceGroupName -Name $workspaceName -ErrorAction SilentlyContinue 40 | if ($law) { 41 | Write-Output "Log Analytics Workspace ($workspaceName) found." 42 | $resourcesExist = "true" 43 | } else { 44 | Write-Output "Log Analytics Workspace ($workspaceName) not found." 45 | } 46 | 47 | # Set a pipeline variable based on resource existence 48 | Write-Output "Setting RESOURCES_EXIST to: $resourcesExist" 49 | echo "##vso[task.setvariable variable=RESOURCES_EXIST;isOutput=true]$resourcesExist" 50 | 51 | # ======================================================================================================== 52 | # Stage: DeployBicep 53 | # This stage deploys the Microsoft Sentinel infrastructure using a Bicep template. 54 | # If the CheckExistingResources stage confirms that resources already exist, this stage will be skipped. 55 | # ======================================================================================================== 56 | - stage: DeployBicep 57 | displayName: 'Deploy Microsoft Sentinel Infrastructure via Bicep' 58 | dependsOn: CheckExistingResources 59 | condition: and(succeeded(), eq(dependencies.CheckExistingResources.outputs['CheckResources.CheckSentinelResources.RESOURCES_EXIST'], 'false')) 60 | jobs: 61 | - job: DeploySentinelResources 62 | displayName: 'Deploy Microsoft Sentinel Resources' 63 | steps: 64 | - task: AzureCLI@2 65 | displayName: 'Deploy Sentinel Infrastructure with Bicep Template' 66 | name: DeployBicepTask 67 | inputs: 68 | azureSubscription: $(azureSubscription) 69 | scriptType: 'bash' 70 | scriptLocation: 'inlineScript' 71 | inlineScript: | 72 | echo "Starting Bicep Deployment..." 73 | az deployment sub create \ 74 | --location '$(REGION)' \ 75 | --template-file Bicep/main.bicep \ 76 | --parameters rgLocation='$(REGION)' rgName='$(RESOURCEGROUP)' lawName='$(WORKSPACENAME)' dailyQuota='$(DAILYQUOTA)' 77 | 78 | # ========================================================================================== 79 | # Stage: EnableSentinelContentHub 80 | # This stage enables Sentinel solutions and configures alert rules. 81 | # It will always run, regardless of whether the Bicep deployment was skipped or executed. 82 | # ========================================================================================== 83 | - stage: EnableSentinelContentHub 84 | displayName: 'Enable Sentinel Solutions and Configure Alert Rules' 85 | dependsOn: 86 | - CheckExistingResources 87 | - DeployBicep 88 | condition: always() # Ensures this stage runs even if DeployBicep is skipped 89 | jobs: 90 | - job: EnableContentHub 91 | displayName: 'Enable Sentinel Solutions and Alert Rules' 92 | steps: 93 | - task: AzurePowerShell@5 94 | continueOnError: true 95 | inputs: 96 | azureSubscription: $(azureSubscription) 97 | ScriptType: 'FilePath' 98 | ScriptPath: '$(Build.SourcesDirectory)/Scripts/Set-SentinelContent.ps1' 99 | ScriptArguments: > 100 | -ResourceGroup '$(RESOURCEGROUP)' 101 | -Workspace '$(WORKSPACENAME)' 102 | -Region '$(REGION)' 103 | -Solutions $(SENTINELSOLUTIONS) 104 | -SeveritiesToInclude $(ARSEVERITIES) 105 | -IsGov 'false' 106 | azurePowerShellVersion: 'LatestVersion' 107 | displayName: "Sentinel Solution Deployment" 108 | -------------------------------------------------------------------------------- /Scripts/README.md: -------------------------------------------------------------------------------- 1 | # Microsoft Sentinel Deployment Automation 2 | 3 | ## Overview 4 | 5 | This PowerShell script automates the deployment of Microsoft Sentinel resources within an Azure environment. It handles the deployment of solutions from the Content Hub, analytics rules, and workbooks, streamlining the configuration of a complete Sentinel environment with minimal manual intervention. 6 | 7 | ## Key Features 8 | 9 | ### Comprehensive Resource Deployment 10 | - **Solutions**: Deploy Microsoft Sentinel solutions from the Content Hub 11 | - **Analytics Rules**: Deploy rules filtered by severity with proper configuration and metadata 12 | - **Workbooks**: Deploy workbooks for each solution 13 | 14 | ### Intelligent Resource Management 15 | - **Unified Status Testing**: Consolidated resource status checking with `Test-SentinelResource` 16 | - **Smart Update Handling**: Skip or force updates based on your requirements 17 | - **Metadata Association**: Proper linking of resources with their metadata 18 | 19 | ### Deployment Controls 20 | - **Granular Solution Management**: Control which solutions to deploy, update, or skip 21 | - **Rule Severity Filtering**: Deploy only rules matching specified severities 22 | - **Workbook Deployment Options**: Control whether to update or redeploy existing workbooks 23 | 24 | ### Error Resilience 25 | - **Graceful Error Handling**: Skip problematic resources rather than failing the entire deployment 26 | - **Status Reporting**: Clear, color-coded status summaries for all resource types 27 | - **Detailed Logging**: Informative messages for tracking deployment progress 28 | 29 | ### Azure Government Support 30 | - **Cloud Environment Detection**: Support for both Azure Commercial and Azure Government clouds 31 | 32 | ## Parameter Reference 33 | 34 | | Parameter | Type | Required | Default | Description | 35 | |-----------|------|----------|---------|-------------| 36 | | `ResourceGroup` | string | Yes | - | Azure Resource Group containing the Sentinel workspace | 37 | | `Workspace` | string | Yes | - | Microsoft Sentinel workspace name | 38 | | `Region` | string | Yes | - | Azure region for deployments | 39 | | `Solutions` | string[] | Yes | - | Array of solution names to deploy | 40 | | `SeveritiesToInclude` | string[] | No | `@("High", "Medium", "Low")` | Analytics rule severities to include | 41 | | `IsGov` | string | No | `"false"` | Set to 'true' for Azure Government cloud | 42 | | `ForceSolutionUpdate` | switch | No | `$false` | Force update of already installed solutions | 43 | | `ForceRuleDeployment` | switch | No | `$false` | Force deployment of rules for already installed solutions | 44 | | `SkipSolutionUpdates` | switch | No | `$false` | Skip updating solutions that need updates | 45 | | `SkipRuleUpdates` | switch | No | `$false` | Skip updating analytics rules that need updates | 46 | | `SkipRuleDeployment` | switch | No | `$false` | Skip deploying analytics rules entirely | 47 | | `SkipWorkbookDeployment` | switch | No | `$false` | Skip deploying workbooks entirely | 48 | | `ForceWorkbookDeployment` | switch | No | `$false` | Force redeployment of existing workbooks | 49 | 50 | ## Usage Examples 51 | 52 | ### Basic Usage 53 | ```powershell 54 | .\Set-SentinelContent.ps1 ` 55 | -ResourceGroup "Security-RG" ` 56 | -Workspace "MySentinelWorkspace" ` 57 | -Region "EastUS" ` 58 | -Solutions "Microsoft 365","Threat Intelligence" ` 59 | -SeveritiesToInclude "High","Medium" 60 | ``` 61 | 62 | ### Advanced Usage (Controlling Updates) 63 | ```powershell 64 | .\Set-SentinelContent.ps1 ` 65 | -ResourceGroup "Security-RG" ` 66 | -Workspace "MySentinelWorkspace" ` 67 | -Region "EastUS" ` 68 | -Solutions "Microsoft 365","Threat Intelligence","Windows Security Events" ` 69 | -SeveritiesToInclude "High","Medium","Low" ` 70 | -ForceSolutionUpdate ` 71 | -SkipRuleUpdates ` 72 | -ForceWorkbookDeployment 73 | ``` 74 | 75 | ### Deployment in Azure Government 76 | ```powershell 77 | .\Set-SentinelContent.ps1 ` 78 | -ResourceGroup "Security-RG" ` 79 | -Workspace "GovSentinelWorkspace" ` 80 | -Region "USGovVirginia" ` 81 | -Solutions "Microsoft 365","Azure Activity" ` 82 | -IsGov "true" 83 | ``` 84 | 85 | ### CI/CD Pipeline Example (Azure DevOps) 86 | ```yaml 87 | variables: 88 | azureSubscription: 'SentinelDeployment' 89 | resourceGroup: 'Sentinel-RG' 90 | workspaceName: 'SentinelWorkspace' 91 | region: 'eastus' 92 | solutions: '"Microsoft 365","Azure Activity","Threat Intelligence"' 93 | severities: '"High","Medium","Low"' 94 | 95 | jobs: 96 | - job: DeploySentinel 97 | displayName: 'Deploy Sentinel Resources' 98 | steps: 99 | - task: AzurePowerShell@5 100 | displayName: 'Deploy Sentinel Solutions and Rules' 101 | inputs: 102 | azureSubscription: $(azureSubscription) 103 | ScriptType: 'FilePath' 104 | ScriptPath: './Set-SentinelContent.ps1' 105 | ScriptArguments: > 106 | -ResourceGroup '$(resourceGroup)' 107 | -Workspace '$(workspaceName)' 108 | -Region '$(region)' 109 | -Solutions $(solutions) 110 | -SeveritiesToInclude $(severities) 111 | azurePowerShellVersion: 'LatestVersion' 112 | ``` 113 | 114 | ## Tested Solutions 115 | 116 | The script has been tested with the following Microsoft Sentinel solutions: 117 | 118 | - Azure Activity 119 | - Azure Key Vault 120 | - Azure Logic Apps 121 | - Azure Network Security Groups 122 | - Microsoft 365 123 | - Microsoft Defender for Cloud 124 | - Microsoft Defender for Cloud Apps 125 | - Microsoft Defender for Endpoint 126 | - Microsoft Defender for Identity 127 | - Microsoft Defender Threat Intelligence 128 | - Microsoft Defender XDR 129 | - Microsoft Entra ID 130 | - Microsoft Purview Insider Risk Management 131 | - Syslog 132 | - Threat Intelligence 133 | - Windows Security Events 134 | - Windows Server DNS 135 | 136 | ## Known Limitations 137 | 138 | - Solutions requiring specific permissions or prerequisites may need additional configuration 139 | - Analytics rules referencing tables/columns not present in your environment will be skipped 140 | - Deprecated rules are skipped by design to prevent deploying outdated content 141 | - Authentication context is required before running the script (Connect-AzAccount or equivalent) 142 | - Some workbooks may have dependencies on specific data sources being configured 143 | 144 | ## How It Works 145 | 146 | The script follows a structured deployment process: 147 | 148 | 1. **Authentication and Setup**: 149 | - Validates Azure authentication 150 | - Sets up the environment based on parameters 151 | - Determines the appropriate API endpoints for Commercial or Government cloud 152 | 153 | 2. **Solution Deployment**: 154 | - Retrieves available solutions from the Content Hub 155 | - Checks which solutions are installed vs. need installation 156 | - Deploys or updates solutions based on parameters 157 | 158 | 3. **Analytics Rule Deployment**: 159 | - Fetches rule templates for deployed solutions 160 | - Filters rules by severity 161 | - Checks rule status using the testing framework 162 | - Deploys missing rules and updates outdated ones 163 | 164 | 4. **Workbook Deployment**: 165 | - Retrieves workbook templates for deployed solutions 166 | - Tests workbook status using the testing framework 167 | - Deploys, updates, or skips workbooks based on parameters 168 | 169 | 5. **Status Reporting**: 170 | - Provides detailed deployment summaries for each resource type 171 | - Color-codes output for easy status identification 172 | 173 | ## Conclusion 174 | 175 | This Microsoft Sentinel deployment script provides a reliable, efficient, and flexible way to automate the deployment of Sentinel resources. With its comprehensive approach to handling solutions, rules, and workbooks, it significantly reduces the manual effort required to set up and maintain a Sentinel environment, while providing granular control over the deployment process. 176 | -------------------------------------------------------------------------------- /Scripts/Set-SentinelContent.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Deploys Microsoft Sentinel Solutions, Analytics Rules, and Workbooks to a specified Microsoft Sentinel workspace. 4 | 5 | .DESCRIPTION 6 | This PowerShell script automates the deployment of Microsoft Sentinel solutions, analytics rules, 7 | and workbooks from the Content Hub into an Azure Sentinel workspace. It provides granular control 8 | over which resources to deploy, update, or skip. The script uses a unified resource testing framework 9 | to ensure consistent status checking across all resource types. 10 | 11 | .PARAMETER ResourceGroup 12 | The name of the Azure Resource Group where the Sentinel workspace is located. 13 | 14 | .PARAMETER Workspace 15 | The name of the Sentinel (Log Analytics) workspace. 16 | 17 | .PARAMETER Region 18 | The Azure region where the workspace is deployed. 19 | 20 | .PARAMETER Solutions 21 | An array of Microsoft Sentinel solutions to deploy. 22 | 23 | .PARAMETER SeveritiesToInclude 24 | An optional list of rule severities to include (e.g., High, Medium, Low). 25 | 26 | .PARAMETER IsGov 27 | Specifies whether the script should target an Azure Government cloud. 28 | 29 | .PARAMETER ForceSolutionUpdate 30 | When specified, forces update of already installed solutions even if they're current. 31 | 32 | .PARAMETER ForceRuleDeployment 33 | When specified, deploys rules for already installed solutions, not just newly deployed ones. 34 | 35 | .PARAMETER SkipSolutionUpdates 36 | When specified, skips updating solutions that need updates. 37 | 38 | .PARAMETER SkipRuleUpdates 39 | When specified, skips updating analytics rules that need updates. 40 | 41 | .PARAMETER SkipRuleDeployment 42 | When specified, skips deploying analytics rules entirely. 43 | 44 | .PARAMETER SkipWorkbookDeployment 45 | When specified, skips deploying workbooks entirely. 46 | 47 | .PARAMETER ForceWorkbookDeployment 48 | When specified, forces redeployment of existing workbooks. 49 | 50 | .NOTES 51 | Author: noodlemctwoodle 52 | Version: 1.0.0 53 | Last Updated: 08/03/2025 54 | GitHub Repository: Sentinel-As-Code 55 | 56 | .EXAMPLE 57 | .\Set-SentinelContent.ps1 -ResourceGroup "Security-RG" -Workspace "MySentinelWorkspace" -Region "East US" -Solutions "Microsoft Defender XDR", "Microsoft 365" -SeveritiesToInclude "High", "Medium" 58 | Deploys "Microsoft Defender XDR" and "Microsoft 365" Sentinel solutions while filtering analytics rules to include only "High" and "Medium" severity incidents. 59 | 60 | .EXAMPLE 61 | .\Set-SentinelContent.ps1 -ResourceGroup "Security-RG" -Workspace "MySentinelWorkspace" -Region "East US" -Solutions "Microsoft Defender XDR", "Microsoft 365" -SeveritiesToInclude "High", "Medium" -IsGov $true 62 | Deploys "Microsoft Defender XDR" and "Microsoft 365" Sentinel solutions while filtering analytics rules to include only "High" and "Medium" severity incidents in an Azure Government cloud environment. 63 | 64 | .EXAMPLE 65 | .\Set-SentinelContent.ps1 -ResourceGroup "Security-RG" -Workspace "MySentinelWorkspace" -Region "East US" -Solutions "Microsoft 365", "Threat Intelligence" -ForceSolutionUpdate -SkipRuleUpdates 66 | Deploys solutions and forces update of already installed solutions, while skipping any rule updates. 67 | #> 68 | 69 | param( 70 | [Parameter(Mandatory = $true)][string]$ResourceGroup, # Azure Resource Group containing the Sentinel workspace 71 | [Parameter(Mandatory = $true)][string]$Workspace, # Microsoft Sentinel workspace name 72 | [Parameter(Mandatory = $true)][string]$Region, # Azure region (location) for deployments 73 | [Parameter(Mandatory = $true)][string[]]$Solutions, # Array of solution names to deploy 74 | [Parameter(Mandatory = $false)][string[]]$SeveritiesToInclude = @("High", "Medium", "Low"), # Analytical rule severities to include 75 | [Parameter(Mandatory = $false)][string]$IsGov = "false", # Set to 'true' for Azure Government cloud 76 | [Parameter(Mandatory = $false)][switch]$ForceSolutionUpdate, # Force update of already installed solutions 77 | [Parameter(Mandatory = $false)][switch]$ForceRuleDeployment, # Force deployment of rules for already installed solutions 78 | [Parameter(Mandatory = $false)][switch]$SkipSolutionUpdates, # Skip updating solutions that need updates 79 | [Parameter(Mandatory = $false)][switch]$SkipRuleUpdates, # Skip updating analytical rules that need updates 80 | [Parameter(Mandatory = $false)][switch]$SkipRuleDeployment, # Skip deploying analytical rules entirely 81 | [Parameter(Mandatory = $false)][switch]$SkipWorkbookDeployment, # Skip deploying workbooks entirely 82 | [Parameter(Mandatory = $false)][switch]$ForceWorkbookDeployment # Force redeployment of existing workbooks 83 | ) 84 | 85 | # Convert the string value to Boolean (supports 'true', '1', 'false', '0') 86 | $IsGov = ($IsGov -eq 'true' -or $IsGov -eq '1') 87 | 88 | Write-Host "GovCloud Mode: $IsGov" 89 | 90 | # Ensure parameters are always treated as arrays, even when a single value is provided 91 | if ($Solutions -isnot [array]) { $Solutions = @($Solutions) } 92 | if ($SeveritiesToInclude -isnot [array]) { $SeveritiesToInclude = @($SeveritiesToInclude) } 93 | 94 | <# 95 | .SYNOPSIS 96 | Authenticates with Azure using the current context or prompts for login. 97 | 98 | .DESCRIPTION 99 | Checks if an Azure context already exists. If not, prompts for authentication, 100 | using the appropriate environment (Government or Public). 101 | 102 | .OUTPUTS 103 | Returns the Azure context object containing subscription information. 104 | #> 105 | 106 | function Connect-ToAzure { 107 | # Retrieve the current Azure context 108 | $context = Get-AzContext 109 | 110 | # If no context exists, authenticate with Azure (for GovCloud if specified) 111 | if (!$context) { 112 | if ($IsGov -eq $true) { 113 | Connect-AzAccount -Environment AzureUSGovernment 114 | } else { 115 | Connect-AzAccount 116 | } 117 | $context = Get-AzContext 118 | } 119 | 120 | return $context 121 | } 122 | 123 | # Establish Azure authentication and retrieve subscription details 124 | $context = Connect-ToAzure 125 | $SubscriptionId = $context.Subscription.Id 126 | Write-Host "Connected to Azure with Subscription: $SubscriptionId" -ForegroundColor Blue 127 | 128 | # Select the appropriate API endpoint based on the environment 129 | $serverUrl = if ($IsGov -eq $true) { 130 | "https://management.usgovcloudapi.net" # Azure Government API endpoint 131 | } else { 132 | "https://management.azure.com" # Azure Public API endpoint 133 | } 134 | 135 | # Construct the base URI for Sentinel API calls 136 | $baseUri = "$serverUrl/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.OperationalInsights/workspaces/$Workspace" 137 | 138 | # Retrieve an authorization token for API requests 139 | $instanceProfile = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile 140 | $profileClient = New-Object -TypeName Microsoft.Azure.Commands.ResourceManager.Common.RMProfileClient -ArgumentList ($instanceProfile) 141 | $token = $profileClient.AcquireAccessToken($context.Subscription.TenantId) 142 | 143 | # Create the authentication header required for REST API calls 144 | $authHeader = @{ 145 | 'Content-Type' = 'application/json' 146 | 'Authorization' = 'Bearer ' + $token.AccessToken 147 | } 148 | 149 | <# 150 | .SYNOPSIS 151 | Evaluates the status of Sentinel resources (Solutions, Rules, Workbooks). 152 | 153 | .DESCRIPTION 154 | Provides a unified interface for checking the status of different Sentinel resource types, 155 | including whether they are installed, need updates, or require deployment. 156 | 157 | .PARAMETER ResourceType 158 | The type of Sentinel resource to evaluate: 'Solution', 'AnalyticsRule', or 'Workbook'. 159 | 160 | .PARAMETER Resource 161 | The resource object being evaluated. 162 | 163 | .PARAMETER InstalledPackages 164 | For Solution resources - array of installed content packages used to determine installation status. 165 | 166 | .PARAMETER ExistingRulesByTemplate 167 | For AnalyticsRule resources - hashtable of existing rules indexed by template name. 168 | 169 | .PARAMETER ExistingRulesByName 170 | For AnalyticsRule resources - hashtable of existing rules indexed by display name. 171 | 172 | .PARAMETER ExistingWorkbooks 173 | For Workbook resources - array of existing workbooks to compare against. 174 | 175 | .OUTPUTS 176 | Returns a hashtable containing status information about the resource. 177 | #> 178 | 179 | function Test-SentinelResource { 180 | [CmdletBinding()] 181 | param ( 182 | [Parameter(Mandatory = $true)] 183 | [ValidateSet('Solution', 'AnalyticsRule', 'Workbook')] 184 | [string]$ResourceType, 185 | 186 | # Common parameters 187 | [Parameter(Mandatory = $true)] 188 | [object]$Resource, 189 | 190 | # Solution-specific parameters 191 | [Parameter(Mandatory = $false)] 192 | [array]$InstalledPackages = @(), 193 | 194 | # AnalyticsRule-specific parameters 195 | [Parameter(Mandatory = $false)] 196 | [hashtable]$ExistingRulesByTemplate, 197 | 198 | [Parameter(Mandatory = $false)] 199 | [hashtable]$ExistingRulesByName, 200 | 201 | # Workbook-specific parameters 202 | [Parameter(Mandatory = $false)] 203 | [array]$ExistingWorkbooks = @() 204 | ) 205 | 206 | # Create a base result object with properties common to all resource types 207 | $result = @{ 208 | ResourceType = $ResourceType 209 | Status = "Unknown" 210 | DisplayName = "" 211 | Reason = "" 212 | } 213 | 214 | # Process based on resource type 215 | switch ($ResourceType) { 216 | 'Solution' { 217 | # Validate required parameters 218 | if ($null -eq $Resource) { 219 | throw "Solution parameter is required when ResourceType is 'Solution'" 220 | } 221 | 222 | # Extract solution display name and ID 223 | $result.DisplayName = if ($Resource.properties.PSObject.Properties.Name -contains "displayName") { 224 | $Resource.properties.displayName 225 | } else { 226 | $Resource.name 227 | } 228 | 229 | $result.SolutionId = $Resource.name 230 | 231 | # If no installed packages, solution can't be installed 232 | if ($null -eq $InstalledPackages -or $InstalledPackages.Count -eq 0) { 233 | $result.Status = "NotInstalled" 234 | $result.Reason = "No installed solutions found" 235 | return $result 236 | } 237 | 238 | # Check for matching installed packages by display name 239 | $matchingPackages = $InstalledPackages | Where-Object { 240 | $_.properties.displayName -eq $result.DisplayName 241 | } 242 | 243 | if ($matchingPackages.Count -gt 0) { 244 | # Solution is installed, check if update is available 245 | $installedPackage = $matchingPackages[0] 246 | 247 | # Compare versions if available on both objects 248 | if ($Resource.properties.PSObject.Properties.Name -contains "version" -and 249 | $installedPackage.properties.PSObject.Properties.Name -contains "version") { 250 | 251 | $availableVersion = $Resource.properties.version 252 | $installedVersion = $installedPackage.properties.version 253 | 254 | if ($availableVersion -gt $installedVersion) { 255 | $result.Status = "NeedsUpdate" 256 | $result.AvailableVersion = $availableVersion 257 | $result.InstalledVersion = $installedVersion 258 | $result.InstalledPackage = $installedPackage 259 | $result.Reason = "Newer version available" 260 | return $result 261 | } 262 | } 263 | 264 | $result.Status = "Installed" 265 | $result.InstalledPackage = $installedPackage 266 | $result.Reason = "Solution is installed and up to date" 267 | return $result 268 | } 269 | 270 | # Check for special indicators in the name (Preview/Deprecated) 271 | if ($result.DisplayName -match "\[Preview\]" -or $result.DisplayName -match "\[Deprecated\]") { 272 | $result.Status = "Special" 273 | $result.Reason = "Solution is marked as Preview or Deprecated" 274 | return $result 275 | } 276 | 277 | # Default case: solution is not installed 278 | $result.Status = "NotInstalled" 279 | $result.Reason = "Solution is not installed" 280 | return $result 281 | } 282 | 283 | 'AnalyticsRule' { 284 | # Validate required parameters 285 | if ($null -eq $Resource) { 286 | throw "RuleTemplate parameter is required when ResourceType is 'AnalyticsRule'" 287 | } 288 | if ($null -eq $ExistingRulesByTemplate) { 289 | throw "ExistingRulesByTemplate parameter is required when ResourceType is 'AnalyticsRule'" 290 | } 291 | if ($null -eq $ExistingRulesByName) { 292 | throw "ExistingRulesByName parameter is required when ResourceType is 'AnalyticsRule'" 293 | } 294 | 295 | # Extract rule details from template 296 | $result.DisplayName = $Resource.properties.mainTemplate.resources.properties[0].displayName 297 | $result.TemplateName = $Resource.properties.mainTemplate.resources[0].name 298 | $result.TemplateVersion = $Resource.properties.mainTemplate.resources.properties[1].version 299 | $result.Severity = $Resource.properties.mainTemplate.resources.properties[0].severity 300 | 301 | # Check if rule is deprecated 302 | if ($result.DisplayName -match "\[Deprecated\]") { 303 | $result.Status = "Deprecated" 304 | $result.ExistingRule = $null 305 | $result.Reason = "Rule is marked as deprecated" 306 | return $result 307 | } 308 | 309 | # Check if rule already exists by template name 310 | if ($ExistingRulesByTemplate.ContainsKey($result.TemplateName)) { 311 | $existingRule = $ExistingRulesByTemplate[$result.TemplateName] 312 | $currentVersion = $existingRule.properties.templateVersion 313 | 314 | # Check if rule needs an update by comparing versions 315 | if ($currentVersion -ne $result.TemplateVersion) { 316 | $result.Status = "NeedsUpdate" 317 | $result.ExistingRule = $existingRule 318 | $result.CurrentVersion = $currentVersion 319 | $result.Reason = "Template version is newer than deployed version" 320 | return $result 321 | } else { 322 | $result.Status = "Current" 323 | $result.ExistingRule = $existingRule 324 | $result.Reason = "Rule exists with current template version" 325 | return $result 326 | } 327 | } 328 | # If not found by template name, check by display name 329 | elseif ($ExistingRulesByName.ContainsKey($result.DisplayName)) { 330 | $result.Status = "NameMatch" 331 | $result.ExistingRule = $ExistingRulesByName[$result.DisplayName] 332 | $result.Reason = "Rule with same name exists but not linked to template" 333 | return $result 334 | } 335 | # Rule doesn't exist and needs to be deployed 336 | else { 337 | $result.Status = "Missing" 338 | $result.ExistingRule = $null 339 | $result.Reason = "Rule does not exist and needs to be deployed" 340 | return $result 341 | } 342 | } 343 | 344 | 'Workbook' { 345 | # Validate required parameters 346 | if ($null -eq $Resource) { 347 | throw "WorkbookTemplate parameter is required when ResourceType is 'Workbook'" 348 | } 349 | 350 | # Extract workbook details 351 | $result.DisplayName = $Resource.properties.displayName 352 | $result.TemplateId = $Resource.properties.contentId 353 | $result.TemplateVersion = $Resource.properties.version 354 | 355 | # Check if workbook is deprecated or in preview 356 | if ($result.DisplayName -match "\[Deprecated\]") { 357 | $result.Status = "Deprecated" 358 | $result.ExistingWorkbook = $null 359 | $result.Reason = "Workbook is marked as deprecated" 360 | return $result 361 | } 362 | 363 | if ($result.DisplayName -match "\[Preview\]") { 364 | $result.Status = "Preview" 365 | $result.Reason = "Workbook is marked as preview" 366 | } 367 | 368 | # Check if workbook already exists 369 | $existingWorkbook = $ExistingWorkbooks | Where-Object { 370 | $_.properties.contentId -eq $result.TemplateId 371 | } | Select-Object -First 1 372 | 373 | if ($existingWorkbook) { 374 | $result.ExistingWorkbook = $existingWorkbook 375 | $currentVersion = $existingWorkbook.properties.version 376 | 377 | # Check if version needs update 378 | if ($currentVersion -ne $result.TemplateVersion) { 379 | $result.Status = "NeedsUpdate" 380 | $result.CurrentVersion = $currentVersion 381 | $result.Reason = "Template version is newer than deployed version" 382 | return $result 383 | } else { 384 | $result.Status = if ($result.Status -eq "Preview") { "PreviewCurrent" } else { "Current" } 385 | $result.Reason = "Workbook exists with current template version" 386 | return $result 387 | } 388 | } 389 | 390 | # Check if workbook with same name exists 391 | $nameMatch = $ExistingWorkbooks | Where-Object { 392 | $_.properties.displayName -eq $result.DisplayName 393 | } | Select-Object -First 1 394 | 395 | if ($nameMatch) { 396 | $result.Status = "NameMatch" 397 | $result.ExistingWorkbook = $nameMatch 398 | $result.Reason = "Workbook with same name exists but not linked to template" 399 | return $result 400 | } 401 | 402 | # Workbook doesn't exist 403 | $result.Status = if ($result.Status -eq "Preview") { "PreviewMissing" } else { "Missing" } 404 | $result.Reason = "Workbook does not exist and needs to be deployed" 405 | return $result 406 | } 407 | } 408 | 409 | # Should not reach here, but just in case 410 | $result.Status = "Unknown" 411 | $result.Reason = "Failed to determine status" 412 | return $result 413 | } 414 | 415 | <# 416 | .SYNOPSIS 417 | Deploys Microsoft Sentinel solutions from the Content Hub. 418 | 419 | .DESCRIPTION 420 | Fetches available Sentinel solutions from the Content Hub, checks their status, 421 | and deploys or updates solutions based on the specified parameters. 422 | 423 | .PARAMETER ForceUpdate 424 | Forces update of solutions that are already installed, even if not required. 425 | 426 | .PARAMETER SkipUpdates 427 | Skips updating solutions that need updates. 428 | 429 | .OUTPUTS 430 | Returns a hashtable containing details of deployed, updated, installed, and failed solutions. 431 | #> 432 | 433 | function Deploy-Solutions { 434 | param( 435 | [Parameter(Mandatory = $false)][switch]$ForceUpdate, 436 | [Parameter(Mandatory = $false)][switch]$SkipUpdates 437 | ) 438 | 439 | Write-Host "Fetching available Sentinel solutions..." -ForegroundColor Yellow 440 | $solutionURL = "$baseUri/providers/Microsoft.SecurityInsights/contentProductPackages?api-version=2024-03-01" 441 | 442 | try { 443 | $availableSolutions = (Invoke-RestMethod -Method "Get" -Uri $solutionURL -Headers $authHeader).value 444 | Write-Host "Successfully fetched $(($availableSolutions | Measure-Object).Count) Sentinel solutions." -ForegroundColor Green 445 | } catch { 446 | Write-Error "❌ ERROR: Failed to fetch Sentinel solutions: $($_.Exception.Message)" 447 | return @{ 448 | Deployed = @() 449 | Updated = @() 450 | Installed = @() 451 | Failed = @() 452 | } 453 | } 454 | 455 | if ($null -eq $availableSolutions -or $availableSolutions.Count -eq 0) { 456 | Write-Error "❌ ERROR: No Sentinel solutions found! Exiting." 457 | return @{ 458 | Deployed = @() 459 | Updated = @() 460 | Installed = @() 461 | Failed = @() 462 | } 463 | } 464 | 465 | # Get installed Content Packages to check solution status 466 | $contentPackagesUrl = "$baseUri/providers/Microsoft.SecurityInsights/contentPackages?api-version=2023-11-01" 467 | try { 468 | $result = Invoke-RestMethod -Method "Get" -Uri $contentPackagesUrl -Headers $authHeader 469 | $installedPackages = if ($result.PSObject.Properties.Name -contains "value") { $result.value } else { @() } 470 | 471 | Write-Host "Successfully fetched $(($installedPackages | Measure-Object).Count) installed solutions." -ForegroundColor Green 472 | } catch { 473 | Write-Warning "Failed to fetch installed solutions: $($_.Exception.Message). Assuming no solutions are installed." 474 | $installedPackages = @() 475 | } 476 | 477 | # Check each requested solution 478 | $solutionsToProcess = @() 479 | $skippedSolutions = @() 480 | $specialSolutions = @() 481 | 482 | foreach ($deploySolution in $Solutions) { 483 | # Find matching solution by name 484 | $matchingSolutions = $availableSolutions | Where-Object { 485 | $_.properties.displayName -eq $deploySolution 486 | } 487 | 488 | if ($matchingSolutions.Count -eq 0) { 489 | Write-Warning "⚠️ Solution '$deploySolution' not found in Content Hub. Skipping." 490 | continue 491 | } 492 | 493 | $singleSolution = $matchingSolutions[0] 494 | $solutionStatus = Test-SentinelResource -ResourceType Solution -Resource $singleSolution -InstalledPackages $installedPackages 495 | 496 | switch ($solutionStatus.Status) { 497 | "Installed" { 498 | if ($ForceUpdate) { 499 | Write-Host "🔄 Solution '$($solutionStatus.DisplayName)' is installed but will be updated due to ForceUpdate." -ForegroundColor Cyan 500 | $solutionsToProcess += [PSCustomObject]@{ 501 | Solution = $singleSolution 502 | Status = $solutionStatus 503 | Action = "Update" 504 | } 505 | } else { 506 | Write-Host "✅ Solution '$($solutionStatus.DisplayName)' is already installed." -ForegroundColor Green 507 | $skippedSolutions += $solutionStatus 508 | } 509 | } 510 | "NeedsUpdate" { 511 | if ($SkipUpdates) { 512 | Write-Host "⏭️ Solution '$($solutionStatus.DisplayName)' needs update but updates are being skipped." -ForegroundColor Yellow 513 | $skippedSolutions += $solutionStatus 514 | } else { 515 | Write-Host "🔄 Solution '$($solutionStatus.DisplayName)' needs update (v$($solutionStatus.InstalledVersion) → v$($solutionStatus.AvailableVersion))." -ForegroundColor Cyan 516 | $solutionsToProcess += [PSCustomObject]@{ 517 | Solution = $singleSolution 518 | Status = $solutionStatus 519 | Action = "Update" 520 | } 521 | } 522 | } 523 | "NotInstalled" { 524 | Write-Host "🚀 Solution '$($solutionStatus.DisplayName)' will be deployed." -ForegroundColor Yellow 525 | $solutionsToProcess += [PSCustomObject]@{ 526 | Solution = $singleSolution 527 | Status = $solutionStatus 528 | Action = "Install" 529 | } 530 | } 531 | "Special" { 532 | if ($ForceUpdate) { 533 | Write-Host "🔄 Solution '$($solutionStatus.DisplayName)' is marked as special but will be deployed due to ForceUpdate." -ForegroundColor Cyan 534 | $solutionsToProcess += [PSCustomObject]@{ 535 | Solution = $singleSolution 536 | Status = $solutionStatus 537 | Action = "Install" 538 | } 539 | } else { 540 | Write-Host "⚠️ Solution '$($solutionStatus.DisplayName)' is marked as special (preview or deprecated)." -ForegroundColor Yellow 541 | $specialSolutions += $solutionStatus 542 | } 543 | } 544 | } 545 | } 546 | 547 | # Deploy or update solutions 548 | $deployedSolutions = @() 549 | $updatedSolutions = @() 550 | $failedSolutions = @() 551 | 552 | foreach ($solutionInfo in $solutionsToProcess) { 553 | $solution = $solutionInfo.Solution 554 | $status = $solutionInfo.Status 555 | $action = $solutionInfo.Action 556 | 557 | Write-Host "Processing $action for solution: $($status.DisplayName)" -ForegroundColor Cyan 558 | 559 | # Get detailed solution information 560 | $solutionURL = "$baseUri/providers/Microsoft.SecurityInsights/contentProductPackages/$($solution.name)?api-version=2024-03-01" 561 | 562 | try { 563 | $detailedSolution = (Invoke-RestMethod -Method "Get" -Uri $solutionURL -Headers $authHeader) 564 | if ($null -eq $detailedSolution) { 565 | Write-Warning "Failed to retrieve details for solution: $($status.DisplayName)" 566 | $failedSolutions += $status 567 | continue 568 | } 569 | } catch { 570 | Write-Error "Unable to retrieve solution details for $($status.DisplayName): $($_.Exception.Message)" 571 | $failedSolutions += $status 572 | continue 573 | } 574 | 575 | $packagedContent = $detailedSolution.properties.packagedContent 576 | 577 | # Ensure `api-version` is included in Content Templates requests 578 | foreach ($resource in $packagedContent.resources) { 579 | if ($null -ne $resource.properties.mainTemplate.metadata.postDeployment) { 580 | $resource.properties.mainTemplate.metadata.postDeployment = $null 581 | } 582 | } 583 | 584 | $installBody = @{ 585 | "properties" = @{ 586 | "parameters" = @{ 587 | "workspace" = @{"value" = $Workspace } 588 | "workspace-location" = @{"value" = $Region } 589 | } 590 | "template" = $packagedContent 591 | "mode" = "Incremental" 592 | } 593 | } 594 | 595 | $deploymentName = "allinone-$($solution.name)".Substring(0, [Math]::Min(64, ("allinone-$($solution.name)").Length)) 596 | 597 | # Ensure `api-version` is correctly formatted in the URL 598 | $installURL = "$serverUrl/subscriptions/$SubscriptionId/resourcegroups/$ResourceGroup/providers/Microsoft.Resources/deployments/$deploymentName" 599 | $installURL = $installURL + "?api-version=2021-04-01" 600 | 601 | # Start deployment 602 | try { 603 | Write-Host "Starting deployment for $($status.DisplayName)..." -ForegroundColor Cyan 604 | 605 | # Convert the body to JSON, handling errors 606 | try { 607 | $jsonBody = $installBody | ConvertTo-Json -EnumsAsStrings -Depth 50 -EscapeHandling EscapeNonAscii 608 | } catch { 609 | Write-Error "❌ Failed to convert installation body to JSON: $($_.Exception.Message)" 610 | $failedSolutions += $status 611 | continue 612 | } 613 | 614 | # Log the URL for debugging 615 | Write-Verbose "Deployment URL: $installURL" 616 | 617 | $deploymentResult = Invoke-RestMethod -Uri $installURL -Method Put -Headers $authHeader -Body $jsonBody 618 | 619 | if ($null -eq $deploymentResult) { 620 | Write-Error "❌ Deployment returned null result for solution: $($status.DisplayName)" 621 | $failedSolutions += $status 622 | continue 623 | } 624 | 625 | Write-Host "✅ Deployment successful for solution: $($status.DisplayName)" -ForegroundColor Green 626 | 627 | if ($action -eq "Update") { 628 | $updatedSolutions += $status 629 | } else { 630 | $deployedSolutions += $status 631 | } 632 | 633 | # Increased delay to mitigate potential rate limiting 634 | Start-Sleep -Milliseconds 1000 635 | } 636 | catch { 637 | Write-Error "❌ Deployment failed for solution: $($status.DisplayName)" 638 | Write-Error "Azure API Error: $($_.Exception.Message)" 639 | 640 | # More detailed error information 641 | if ($_.Exception.Response) { 642 | $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) 643 | $responseBody = $reader.ReadToEnd() 644 | $reader.Close() 645 | Write-Error "Response status code: $($_.Exception.Response.StatusCode.value__)" 646 | Write-Error "Response body: $responseBody" 647 | } 648 | 649 | $failedSolutions += $status 650 | } 651 | } 652 | 653 | # Create a summary of what was done 654 | Write-Host "Solution Deployment Summary:" -ForegroundColor Blue 655 | Write-Host " - Installed: $($deployedSolutions.Count)" -ForegroundColor Green 656 | Write-Host " - Updated: $($updatedSolutions.Count)" -ForegroundColor Cyan 657 | Write-Host " - Skipped: $($skippedSolutions.Count)" -ForegroundColor Yellow 658 | if ($specialSolutions.Count -gt 0) { 659 | Write-Host " - Special (preview/deprecated): $($specialSolutions.Count)" -ForegroundColor Yellow 660 | } 661 | if ($failedSolutions.Count -gt 0) { 662 | Write-Host " - Failed: $($failedSolutions.Count)" -ForegroundColor Red 663 | } 664 | 665 | # Return the combined list of all solutions that should have rules deployed 666 | return @{ 667 | Deployed = $deployedSolutions | ForEach-Object { $_.DisplayName } 668 | Updated = $updatedSolutions | ForEach-Object { $_.DisplayName } 669 | Installed = $skippedSolutions | ForEach-Object { $_.DisplayName } 670 | Failed = $failedSolutions | ForEach-Object { $_.DisplayName } 671 | } 672 | } 673 | 674 | <# 675 | .SYNOPSIS 676 | Deploys analytical rules for the specified Microsoft Sentinel solutions. 677 | 678 | .DESCRIPTION 679 | Fetches analytical rule templates, filters them based on specified solutions and 680 | severities, and deploys or updates them as needed. 681 | 682 | .PARAMETER DeployedSolutions 683 | Array of solution names to deploy analytical rules for. If empty, all available rules are considered. 684 | 685 | .PARAMETER SkipTunedRulesText 686 | Text to identify rules that should be skipped (manually tuned/customized). 687 | 688 | .PARAMETER SkipUpdates 689 | Skips updating rules that need updates. 690 | #> 691 | 692 | function Deploy-AnalyticalRules { 693 | param( 694 | [Parameter(Mandatory = $false)][string[]]$DeployedSolutions = @(), 695 | [Parameter(Mandatory = $false)][string]$SkipTunedRulesText = "", 696 | [Parameter(Mandatory = $false)][switch]$SkipUpdates 697 | ) 698 | 699 | # Wait for solutions to finish deploying before deploying rules 700 | Write-Host "Waiting for solution deployment to complete..." -ForegroundColor Yellow 701 | Start-Sleep -Seconds 90 # Delay to ensure solutions are fully deployed before proceeding 702 | 703 | Write-Host "Fetching available Sentinel solutions..." -ForegroundColor Yellow 704 | $solutionURL = "$baseUri/providers/Microsoft.SecurityInsights/contentProductPackages?api-version=2024-03-01" 705 | 706 | try { 707 | $allSolutions = (Invoke-RestMethod -Method "Get" -Uri $solutionURL -Headers $authHeader).value 708 | Write-Host "✅ Successfully fetched Sentinel solutions." -ForegroundColor Green 709 | } catch { 710 | Write-Error "❌ ERROR: Failed to fetch Sentinel solutions: $($_.Exception.Message)" 711 | return 712 | } 713 | 714 | # Get all existing deployed Analytics Rules to check for duplicates and updates 715 | Write-Host "Fetching existing deployed Analytics Rules..." -ForegroundColor Yellow 716 | $existingRulesURL = "$baseUri/providers/Microsoft.SecurityInsights/alertRules?api-version=2022-12-01-preview" 717 | 718 | try { 719 | $existingRules = (Invoke-RestMethod -Uri $existingRulesURL -Method Get -Headers $authHeader).value 720 | Write-Host "✅ Successfully fetched $($existingRules.Count) existing deployed Analytics Rules." -ForegroundColor Green 721 | } catch { 722 | Write-Error "❌ ERROR: Failed to fetch existing deployed Analytics Rules: $($_.Exception.Message)" 723 | return 724 | } 725 | 726 | # Create lookup tables for existing rules by displayName and template name 727 | $existingRulesByName = @{} 728 | $existingRulesByTemplate = @{} 729 | 730 | foreach ($rule in $existingRules) { 731 | if ($rule.properties.displayName) { 732 | $existingRulesByName[$rule.properties.displayName] = $rule 733 | } 734 | 735 | if ($rule.properties.alertRuleTemplateName) { 736 | $existingRulesByTemplate[$rule.properties.alertRuleTemplateName] = $rule 737 | } 738 | } 739 | 740 | Write-Host "Fetching available Analytics Rule templates..." -ForegroundColor Yellow 741 | $ruleTemplateURL = "$baseUri/providers/Microsoft.SecurityInsights/contentTemplates?api-version=2023-05-01-preview" 742 | $ruleTemplateURL += "&%24filter=(properties%2FcontentKind%20eq%20'AnalyticsRule')" 743 | 744 | try { 745 | $ruleTemplates = (Invoke-RestMethod -Uri $ruleTemplateURL -Method Get -Headers $authHeader).value 746 | Write-Host "✅ Successfully fetched $($ruleTemplates.Count) available Analytics Rule templates." -ForegroundColor Green 747 | } catch { 748 | Write-Error "❌ ERROR: Failed to fetch Analytics Rule templates: $($_.Exception.Message)" 749 | return 750 | } 751 | 752 | if ($null -eq $ruleTemplates -or $ruleTemplates.Count -eq 0) { 753 | Write-Error "❌ ERROR: No Analytical Rule templates found! Exiting." 754 | return 755 | } 756 | 757 | # Filter rule templates to only include those from the specified solutions 758 | if ($DeployedSolutions.Count -gt 0) { 759 | Write-Host "Targeting rules for solutions: $($DeployedSolutions -join ', ')" -ForegroundColor Magenta 760 | 761 | # Find solutions that match the deployed solutions 762 | $relevantSolutions = $allSolutions | Where-Object { 763 | $_.properties.displayName -in $DeployedSolutions 764 | } 765 | 766 | # Extract solution IDs 767 | $deployedSolutionIds = @() 768 | foreach ($solution in $relevantSolutions) { 769 | if ($solution.properties.contentId) { $deployedSolutionIds += $solution.properties.contentId } 770 | if ($solution.properties.packageId) { $deployedSolutionIds += $solution.properties.packageId } 771 | } 772 | 773 | # Filter rule templates to only include those from the deployed solutions 774 | $rulesToProcess = $ruleTemplates | Where-Object { 775 | $deployedSolutionIds -contains $_.properties.packageId 776 | } 777 | } else { 778 | # If no solutions specified, use all templates 779 | $rulesToProcess = $ruleTemplates 780 | } 781 | 782 | Write-Host "Found $($rulesToProcess.Count) applicable Analytics Rule templates." -ForegroundColor Cyan 783 | 784 | if ($rulesToProcess.Count -eq 0) { 785 | Write-Warning "No rule templates found for the specified solutions." 786 | return 787 | } 788 | 789 | $BaseAlertUri = "$baseUri/providers/Microsoft.SecurityInsights/alertRules/" 790 | $BaseMetaURI = "$baseUri/providers/Microsoft.SecurityInsights/metadata/analyticsrule-" 791 | 792 | Write-Host "Severities to include: $($SeveritiesToInclude -join ', ')" -ForegroundColor Magenta 793 | 794 | # Counters for summary 795 | $deployedCount = 0 796 | $updatedCount = 0 797 | $skippedCount = 0 798 | $deprecatedCount = 0 799 | $failedCount = 0 800 | 801 | # Check each rule template using the Test-SentinelResource function 802 | $rulesToDeploy = @() 803 | 804 | foreach ($template in $rulesToProcess) { 805 | # Extract severity for filtering 806 | $severity = $template.properties.mainTemplate.resources.properties[0].severity 807 | 808 | # Check if rule matches severity filter 809 | if ($SeveritiesToInclude.Count -eq 0 -or $SeveritiesToInclude -contains $severity) { 810 | # Use the consolidated test function 811 | $ruleStatus = Test-SentinelResource -ResourceType AnalyticsRule -Resource $template -ExistingRulesByTemplate $existingRulesByTemplate -ExistingRulesByName $existingRulesByName 812 | 813 | switch ($ruleStatus.Status) { 814 | "Deprecated" { 815 | Write-Host "⚠️ Skipping Deprecated Rule: $($ruleStatus.DisplayName)" -ForegroundColor Yellow 816 | $deprecatedCount++ 817 | continue 818 | } 819 | "Current" { 820 | Write-Host "⏭️ Skipping rule (already exists with current version): $($ruleStatus.DisplayName)" -ForegroundColor Yellow 821 | $skippedCount++ 822 | continue 823 | } 824 | "NeedsUpdate" { 825 | if ($SkipUpdates) { 826 | Write-Host "⏭️ Skipping update for rule: $($ruleStatus.DisplayName) (current version: $($ruleStatus.CurrentVersion))" -ForegroundColor Yellow 827 | $skippedCount++ 828 | continue 829 | } else { 830 | Write-Host "🔄 Updating existing rule: $($ruleStatus.DisplayName) (Version $($ruleStatus.CurrentVersion) → $($ruleStatus.TemplateVersion))" -ForegroundColor Cyan 831 | $rulesToDeploy += [PSCustomObject]@{ 832 | Template = $template 833 | DisplayName = $ruleStatus.DisplayName 834 | Severity = $severity 835 | TemplateName = $ruleStatus.TemplateName 836 | TemplateVersion = $ruleStatus.TemplateVersion 837 | ExistingRule = $ruleStatus.ExistingRule 838 | NeedsUpdate = $true 839 | } 840 | } 841 | } 842 | "NameMatch" { 843 | Write-Host "⏭️ Skipping rule (name match): $($ruleStatus.DisplayName)" -ForegroundColor Yellow 844 | $skippedCount++ 845 | continue 846 | } 847 | "Missing" { 848 | #Write-Host "🚀 Deploying new Analytics Rule: $($ruleStatus.DisplayName)" -ForegroundColor Cyan 849 | $rulesToDeploy += [PSCustomObject]@{ 850 | Template = $template 851 | DisplayName = $ruleStatus.DisplayName 852 | Severity = $severity 853 | TemplateName = $ruleStatus.TemplateName 854 | TemplateVersion = $ruleStatus.TemplateVersion 855 | ExistingRule = $null 856 | NeedsUpdate = $false 857 | } 858 | } 859 | } 860 | } 861 | } 862 | 863 | # Deploy or update rules 864 | foreach ($ruleToDeploy in $rulesToDeploy) { 865 | $template = $ruleToDeploy.Template 866 | $displayName = $ruleToDeploy.DisplayName 867 | $templateName = $ruleToDeploy.TemplateName 868 | $templateVersion = $ruleToDeploy.TemplateVersion 869 | $existingRule = $ruleToDeploy.ExistingRule 870 | $needsUpdate = $ruleToDeploy.NeedsUpdate 871 | 872 | # Prepare rule properties 873 | $kind = $template.properties.mainTemplate.resources[0].kind 874 | $properties = $template.properties.mainTemplate.resources[0].properties 875 | $properties.enabled = $true 876 | 877 | # Add linking fields 878 | $properties | Add-Member -NotePropertyName "alertRuleTemplateName" -NotePropertyValue $templateName -Force 879 | $properties | Add-Member -NotePropertyName "templateVersion" -NotePropertyValue $templateVersion -Force 880 | 881 | # If updating an existing rule, preserve custom entity mappings and details 882 | if ($needsUpdate -and $existingRule) { 883 | # Preserve custom entity mappings if they exist 884 | if ($existingRule.properties.PSObject.Properties.Name -contains "entityMappings") { 885 | Write-Host " - Preserving custom entity mappings" -ForegroundColor Cyan 886 | $properties.entityMappings = $existingRule.properties.entityMappings 887 | } 888 | 889 | # Preserve custom details if they exist 890 | if ($existingRule.properties.PSObject.Properties.Name -contains "customDetails") { 891 | Write-Host " - Preserving custom details" -ForegroundColor Cyan 892 | $properties | Add-Member -NotePropertyName "customDetails" -NotePropertyValue $existingRule.properties.customDetails -Force 893 | } 894 | } 895 | # Otherwise ensure entity mappings is an array 896 | elseif ($properties.PSObject.Properties.Name -contains "entityMappings") { 897 | if ($properties.entityMappings -isnot [System.Array]) { 898 | $properties.entityMappings = @($properties.entityMappings) 899 | } 900 | } 901 | 902 | # Ensure requiredDataConnectors is an object 903 | if ($properties.PSObject.Properties.Name -contains "requiredDataConnectors") { 904 | if ($properties.requiredDataConnectors -is [System.Array] -and $properties.requiredDataConnectors.Count -eq 1) { 905 | $properties.requiredDataConnectors = $properties.requiredDataConnectors[0] 906 | } 907 | } 908 | 909 | # Fix Grouping Configuration 910 | if ($properties.PSObject.Properties.Name -contains "incidentConfiguration") { 911 | if ($properties.incidentConfiguration.PSObject.Properties.Name -contains "groupingConfiguration") { 912 | if (-not $properties.incidentConfiguration.groupingConfiguration) { 913 | $properties.incidentConfiguration | Add-Member -NotePropertyName "groupingConfiguration" -NotePropertyValue @{ 914 | matchingMethod = "AllEntities" 915 | lookbackDuration = "PT1H" 916 | } 917 | } else { 918 | # Ensure `matchingMethod` exists 919 | if (-not ($properties.incidentConfiguration.groupingConfiguration.PSObject.Properties.Name -contains "matchingMethod")) { 920 | $properties.incidentConfiguration.groupingConfiguration | Add-Member -NotePropertyName "matchingMethod" -NotePropertyValue "AllEntities" 921 | } 922 | 923 | # Ensure `lookbackDuration` is in ISO 8601 format 924 | if ($properties.incidentConfiguration.groupingConfiguration.PSObject.Properties.Name -contains "lookbackDuration") { 925 | $lookbackDuration = $properties.incidentConfiguration.groupingConfiguration.lookbackDuration 926 | if ($lookbackDuration -match "^(\d+)(h|d|m)$") { 927 | $timeValue = $matches[1] 928 | $timeUnit = $matches[2] 929 | switch ($timeUnit) { 930 | "h" { $isoDuration = "PT${timeValue}H" } 931 | "d" { $isoDuration = "P${timeValue}D" } 932 | "m" { $isoDuration = "PT${timeValue}M" } 933 | } 934 | $properties.incidentConfiguration.groupingConfiguration.lookbackDuration = $isoDuration 935 | } 936 | } 937 | } 938 | } 939 | } 940 | 941 | # Create JSON body based on rule type 942 | $body = @{ 943 | "kind" = $kind 944 | "properties" = $properties 945 | } 946 | 947 | # For updates, use existing rule ID; for new rules, generate a GUID 948 | $ruleId = if ($needsUpdate) { $existingRule.name } else { (New-Guid).Guid } 949 | $alertUri = "$BaseAlertUri$ruleId" + "?api-version=2022-12-01-preview" 950 | 951 | try { 952 | $jsonBody = $body | ConvertTo-Json -Depth 50 -Compress 953 | $verdict = Invoke-RestMethod -Uri $alertUri -Method Put -Headers $authHeader -Body $jsonBody 954 | 955 | if ($needsUpdate) { 956 | Write-Host "✅ Successfully updated rule: $displayName" -ForegroundColor Green 957 | $updatedCount++ 958 | } else { 959 | Write-Host "✅ Successfully deployed rule: $displayName" -ForegroundColor Green 960 | $deployedCount++ 961 | } 962 | 963 | # Find the solution for this rule 964 | $solution = $allSolutions | Where-Object { 965 | ($_.properties.contentId -eq $template.properties.packageId) -or 966 | ($_.properties.packageId -eq $template.properties.packageId) 967 | } | Select-Object -First 1 968 | 969 | if ($solution) { 970 | $sourceName = $solution.properties.displayName 971 | $sourceId = $solution.name 972 | } else { 973 | $sourceName = "Unknown Solution" 974 | $sourceId = "Unknown-ID" 975 | Write-Warning "⚠️ No matching solution found for: $displayName" 976 | } 977 | 978 | # Create metadata 979 | $metaBody = @{ 980 | "apiVersion" = "2022-01-01-preview" 981 | "name" = "analyticsrule-" + $verdict.name 982 | "type" = "Microsoft.OperationalInsights/workspaces/providers/metadata" 983 | "id" = $null 984 | "properties" = @{ 985 | "contentId" = $templateName 986 | "parentId" = $verdict.id 987 | "kind" = "AnalyticsRule" 988 | "version" = $templateVersion 989 | "source" = @{ 990 | "kind" = "Solution" 991 | "name" = $sourceName 992 | "sourceId" = $sourceId 993 | } 994 | } 995 | } 996 | 997 | # Send metadata update 998 | $metaUri = "$BaseMetaURI$($verdict.name)?api-version=2022-01-01-preview" 999 | Invoke-RestMethod -Uri $metaUri -Method Put -Headers $authHeader -Body ($metaBody | ConvertTo-Json -Depth 5 -Compress) | Out-Null 1000 | 1001 | # Update lookup tables with newly deployed/updated rule 1002 | $existingRulesByName[$displayName] = $verdict 1003 | $existingRulesByTemplate[$templateName] = $verdict 1004 | 1005 | } catch { 1006 | if ($_.ErrorDetails.Message -match "One of the tables does not exist") { 1007 | Write-Warning "⏭️ Skipping $displayName due to missing tables in the environment." 1008 | } elseif ($_.ErrorDetails.Message -match "The given column") { 1009 | Write-Warning "⏭️ Skipping $displayName due to missing column in the query." 1010 | } elseif ($_.ErrorDetails.Message -match "FailedToResolveScalarExpression|SemanticError") { 1011 | Write-Warning "⏭️ Skipping $displayName due to an invalid expression in the query." 1012 | } else { 1013 | Write-Error "❌ ERROR: Deployment failed for Analytical Rule: $displayName" 1014 | Write-Error "Azure API Error: $($_.Exception.Message)" 1015 | } 1016 | $skippedCount++ 1017 | } 1018 | } 1019 | 1020 | # Display summary 1021 | Write-Host "Analytics Rules Deployment Summary:" -ForegroundColor Blue 1022 | Write-Host " - Installed: $deployedCount" -ForegroundColor Green 1023 | Write-Host " - Updated: $updatedCount" -ForegroundColor Cyan 1024 | Write-Host " - Skipped: $skippedCount" -ForegroundColor Yellow 1025 | if ($deprecatedCount -gt 0) { 1026 | Write-Host " - Deprecated: $deprecatedCount" -ForegroundColor Yellow 1027 | } 1028 | if ($failedCount -gt 0) { 1029 | Write-Host " - Failed: $failedCount" -ForegroundColor Red 1030 | } 1031 | } 1032 | 1033 | # Function to deploy workbooks 1034 | <# 1035 | .SYNOPSIS 1036 | Deploys Microsoft Sentinel workbooks for specified solutions. 1037 | 1038 | .DESCRIPTION 1039 | Fetches workbook templates from the Content Hub, filters them based on specified solutions, 1040 | and deploys or updates them as needed. 1041 | 1042 | .PARAMETER DeployedSolutions 1043 | Array of solution names to deploy workbooks for. If empty, all available workbooks are considered. 1044 | 1045 | .PARAMETER DeployExistingWorkbooks 1046 | When specified, redeploys workbooks that are already installed with current versions. 1047 | 1048 | .PARAMETER SkipUpdates 1049 | Skips updating workbooks that need updates. 1050 | #> 1051 | 1052 | function Deploy-SolutionWorkbooks { 1053 | param( 1054 | [Parameter(Mandatory = $false)][string[]]$DeployedSolutions = @(), 1055 | [Parameter(Mandatory = $false)][switch]$DeployExistingWorkbooks, # Redeploy workbooks even if they exist 1056 | [Parameter(Mandatory = $false)][switch]$SkipUpdates # Skip updating workbooks that need updates 1057 | ) 1058 | 1059 | Write-Host "Deploying workbooks for installed solutions..." -ForegroundColor Yellow 1060 | 1061 | # Get all workbook templates from Content Hub 1062 | Write-Host "Getting all workbook templates from Content Hub..." -ForegroundColor Cyan 1063 | $workbookTemplateURL = "$baseUri/providers/Microsoft.SecurityInsights/contentTemplates?api-version=2023-05-01-preview" 1064 | $workbookTemplateURL += "&%24filter=(properties%2FcontentKind%20eq%20'Workbook')" 1065 | 1066 | try { 1067 | $workbookTemplates = (Invoke-RestMethod -Uri $workbookTemplateURL -Method Get -Headers $authHeader).value 1068 | Write-Host "✅ Successfully fetched $($workbookTemplates.Count) workbook templates." -ForegroundColor Green 1069 | } catch { 1070 | Write-Error "❌ ERROR: Failed to fetch workbook templates: $($_.Exception.Message)" 1071 | return 1072 | } 1073 | 1074 | if ($null -eq $workbookTemplates -or $workbookTemplates.Count -eq 0) { 1075 | Write-Warning "No workbook templates found in Content Hub." 1076 | return 1077 | } 1078 | 1079 | # Filter workbook templates to those from deployed solutions 1080 | $relevantWorkbooks = @() 1081 | 1082 | if ($DeployedSolutions.Count -gt 0) { 1083 | # Get all solutions to find workbooks related to deployed solutions 1084 | $solutionURL = "$baseUri/providers/Microsoft.SecurityInsights/contentProductPackages?api-version=2024-03-01" 1085 | 1086 | try { 1087 | $allSolutions = (Invoke-RestMethod -Method "Get" -Uri $solutionURL -Headers $authHeader).value 1088 | 1089 | # Find solutions that match the deployed solutions 1090 | $relevantSolutions = $allSolutions | Where-Object { 1091 | $_.properties.displayName -in $DeployedSolutions 1092 | } 1093 | 1094 | # Extract solution IDs 1095 | $deployedSolutionIds = @() 1096 | foreach ($solution in $relevantSolutions) { 1097 | if ($solution.properties.contentId) { $deployedSolutionIds += $solution.properties.contentId } 1098 | if ($solution.properties.packageId) { $deployedSolutionIds += $solution.properties.packageId } 1099 | } 1100 | 1101 | # Filter workbook templates to those from deployed solutions 1102 | $relevantWorkbooks = $workbookTemplates | Where-Object { 1103 | $deployedSolutionIds -contains $_.properties.packageId 1104 | } 1105 | } catch { 1106 | Write-Error "❌ ERROR: Failed to fetch Sentinel solutions: $($_.Exception.Message)" 1107 | return 1108 | } 1109 | } else { 1110 | # If no specific solutions provided, use all templates 1111 | $relevantWorkbooks = $workbookTemplates 1112 | } 1113 | 1114 | Write-Host "Found $($relevantWorkbooks.Count) workbooks associated with deployed solutions." -ForegroundColor Cyan 1115 | 1116 | # Get existing workbooks to check which ones to skip 1117 | Write-Host "Checking for existing workbooks..." -ForegroundColor Cyan 1118 | $workbookMetadataURL = "$baseUri/providers/Microsoft.SecurityInsights/metadata?api-version=2023-05-01-preview" 1119 | $workbookMetadataURL += "&%24filter=(properties%2FKind%20eq%20'Workbook')" 1120 | 1121 | try { 1122 | $workbookMetadata = (Invoke-RestMethod -Uri $workbookMetadataURL -Method Get -Headers $authHeader).value 1123 | Write-Host "✅ Successfully fetched metadata for $($workbookMetadata.Count) existing workbooks." -ForegroundColor Green 1124 | } catch { 1125 | Write-Warning "Failed to fetch workbook metadata: $($_.Exception.Message)" 1126 | $workbookMetadata = @() 1127 | } 1128 | 1129 | # Counters for tracking progress 1130 | $deployedCount = 0 1131 | $updatedCount = 0 1132 | $skippedCount = 0 1133 | $deprecatedCount = 0 1134 | $failedCount = 0 1135 | 1136 | # Create a list of workbooks to deploy or update 1137 | $workbooksToProcess = @() 1138 | 1139 | foreach ($workbookTemplate in $relevantWorkbooks) { 1140 | # Use consolidated test function 1141 | $workbookStatus = Test-SentinelResource -ResourceType Workbook -Resource $workbookTemplate -ExistingWorkbooks $workbookMetadata 1142 | 1143 | switch ($workbookStatus.Status) { 1144 | { $_ -in "Current", "PreviewCurrent" } { 1145 | if (-not $DeployExistingWorkbooks) { 1146 | Write-Host "⏭️ Skipping workbook (already exists with current version): $($workbookStatus.DisplayName)" -ForegroundColor Yellow 1147 | $skippedCount++ 1148 | continue 1149 | } else { 1150 | # Force redeploy even though current 1151 | $workbooksToProcess += [PSCustomObject]@{ 1152 | Template = $workbookTemplate 1153 | DisplayName = $workbookStatus.DisplayName 1154 | ExistingWorkbook = $workbookStatus.ExistingWorkbook 1155 | Action = "Redeploy" 1156 | } 1157 | } 1158 | } 1159 | "NeedsUpdate" { 1160 | if ($SkipUpdates) { 1161 | Write-Host "⏭️ Skipping update for workbook: $($workbookStatus.DisplayName) (current version: $($workbookStatus.CurrentVersion))" -ForegroundColor Yellow 1162 | $skippedCount++ 1163 | continue 1164 | } else { 1165 | Write-Host "🔄 Workbook needs update: $($workbookStatus.DisplayName) (Version $($workbookStatus.CurrentVersion) → $($workbookStatus.TemplateVersion))" -ForegroundColor Cyan 1166 | $workbooksToProcess += [PSCustomObject]@{ 1167 | Template = $workbookTemplate 1168 | DisplayName = $workbookStatus.DisplayName 1169 | ExistingWorkbook = $workbookStatus.ExistingWorkbook 1170 | Action = "Update" 1171 | } 1172 | } 1173 | } 1174 | { $_ -in "Missing", "PreviewMissing" } { 1175 | #Write-Host "🚀 New workbook to deploy: $($workbookStatus.DisplayName)" -ForegroundColor Cyan 1176 | $workbooksToProcess += [PSCustomObject]@{ 1177 | Template = $workbookTemplate 1178 | DisplayName = $workbookStatus.DisplayName 1179 | ExistingWorkbook = $null 1180 | Action = "Deploy" 1181 | } 1182 | } 1183 | "Deprecated" { 1184 | Write-Host "⚠️ Skipping deprecated workbook: $($workbookStatus.DisplayName)" -ForegroundColor Yellow 1185 | $deprecatedCount++ 1186 | continue 1187 | } 1188 | "NameMatch" { 1189 | Write-Host "⏭️ Skipping workbook (name match): $($workbookStatus.DisplayName)" -ForegroundColor Yellow 1190 | $skippedCount++ 1191 | continue 1192 | } 1193 | } 1194 | } 1195 | 1196 | # Process workbooks 1197 | foreach ($workbookInfo in $workbooksToProcess) { 1198 | $workbookTemplate = $workbookInfo.Template 1199 | $displayName = $workbookInfo.DisplayName 1200 | $existingWorkbook = $workbookInfo.ExistingWorkbook 1201 | $action = $workbookInfo.Action 1202 | 1203 | # Get detailed workbook template 1204 | $workbookDetailURL = "$baseUri/providers/Microsoft.SecurityInsights/contentTemplates/$($workbookTemplate.name)?api-version=2023-05-01-preview" 1205 | 1206 | try { 1207 | $workbookDetail = (Invoke-RestMethod -Uri $workbookDetailURL -Method Get -Headers $authHeader).properties.mainTemplate.resources 1208 | 1209 | # Extract workbook and metadata resources 1210 | $workbookResource = $workbookDetail | Where-Object type -eq 'Microsoft.Insights/workbooks' 1211 | $metadataResource = $workbookDetail | Where-Object type -eq 'Microsoft.OperationalInsights/workspaces/providers/metadata' 1212 | 1213 | if (-not $workbookResource) { 1214 | Write-Warning "Could not find workbook resource in template: $displayName" 1215 | $failedCount++ 1216 | continue 1217 | } 1218 | 1219 | # Generate new GUID for the workbook or use existing ID for updates 1220 | $guid = if ($action -eq "Update" -and $existingWorkbook) { 1221 | # Extract GUID from the parentId 1222 | if ($existingWorkbook.properties.parentId -match '/([^/]+)$') { 1223 | $matches[1] 1224 | } else { 1225 | # Fallback to new GUID if we can't extract it 1226 | (New-Guid).Guid 1227 | } 1228 | } else { 1229 | (New-Guid).Guid 1230 | } 1231 | 1232 | # Prepare workbook for deployment 1233 | $newWorkbook = $workbookResource | Select-Object * -ExcludeProperty apiVersion, metadata, name 1234 | $newWorkbook | Add-Member -NotePropertyName name -NotePropertyValue $guid 1235 | $newWorkbook | Add-Member -NotePropertyName location -NotePropertyValue $Region -Force 1236 | 1237 | # Ensure required properties are present 1238 | if (-not ($newWorkbook.PSObject.Properties.Name -contains "kind")) { 1239 | $newWorkbook | Add-Member -NotePropertyName kind -NotePropertyValue "shared" 1240 | } 1241 | 1242 | if (-not ($newWorkbook.PSObject.Properties.Name -contains "tags")) { 1243 | $newWorkbook | Add-Member -NotePropertyName tags -NotePropertyValue @{ 1244 | "hidden-title" = $displayName 1245 | "source" = "Microsoft Sentinel" 1246 | } 1247 | } 1248 | 1249 | $workbookPayload = $newWorkbook | ConvertTo-Json -Depth 50 -EnumsAsStrings 1250 | 1251 | # If updating, delete the old workbook first if it's a different ID 1252 | if ($action -eq "Update" -and $existingWorkbook) { 1253 | $oldMetadataName = $existingWorkbook.name 1254 | $oldWorkbookName = $oldMetadataName -replace 'workbook-', '' 1255 | 1256 | if ($oldWorkbookName -ne $guid) { 1257 | # Delete old workbook 1258 | Write-Host " - Deleting old workbook version" -ForegroundColor Cyan 1259 | $deleteWorkbookPath = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Insights/workbooks/$oldWorkbookName" 1260 | $deleteWorkbookPath += "?api-version=2022-04-01" 1261 | 1262 | $deleteResult = Invoke-AzRestMethod -Path $deleteWorkbookPath -Method DELETE 1263 | 1264 | # Check result of workbook deletion 1265 | if ($deleteResult.StatusCode -in 200, 201, 204) { 1266 | Write-Host " ✅ Successfully deleted old workbook" -ForegroundColor Green 1267 | } 1268 | elseif ($deleteResult.StatusCode -eq 404) { 1269 | Write-Host " ⚠️ Old workbook not found (already deleted)" -ForegroundColor Yellow 1270 | } 1271 | else { 1272 | Write-Warning " ⚠️ Failed to delete old workbook: Status $($deleteResult.StatusCode)" 1273 | Write-Verbose "Response: $($deleteResult.Content)" 1274 | } 1275 | 1276 | # Delete old metadata 1277 | $deleteMetadataPath = "$baseUri/providers/Microsoft.SecurityInsights/metadata/$oldMetadataName".Replace("https://management.azure.com", "") 1278 | $deleteMetadataPath += "?api-version=2023-05-01-preview" 1279 | 1280 | $deleteMetadataResult = Invoke-AzRestMethod -Path $deleteMetadataPath -Method DELETE 1281 | 1282 | # Check result of metadata deletion 1283 | if ($deleteMetadataResult.StatusCode -in 200, 201, 204) { 1284 | Write-Host " ✅ Successfully deleted workbook metadata" -ForegroundColor Green 1285 | } 1286 | elseif ($deleteMetadataResult.StatusCode -eq 404) { 1287 | Write-Host " ⚠️ Old workbook metadata not found (already deleted)" -ForegroundColor Yellow 1288 | } 1289 | else { 1290 | Write-Warning " ⚠️ Failed to delete workbook metadata: Status $($deleteMetadataResult.StatusCode)" 1291 | Write-Verbose "Response: $($deleteMetadataResult.Content)" 1292 | } 1293 | } 1294 | } 1295 | 1296 | # Create/update workbook using Invoke-AzRestMethod 1297 | $workbookCreatePath = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Insights/workbooks/$guid" 1298 | $workbookCreatePath += "?api-version=2022-04-01" 1299 | 1300 | $workbookResult = Invoke-AzRestMethod -Path $workbookCreatePath -Method PUT -Payload $workbookPayload 1301 | 1302 | if ($workbookResult.StatusCode -in 200, 201) { 1303 | if ($action -eq "Update") { 1304 | Write-Host "✅ Successfully updated workbook: $displayName" -ForegroundColor Green 1305 | $updatedCount++ 1306 | } else { 1307 | Write-Host "✅ Successfully deployed workbook: $displayName" -ForegroundColor Green 1308 | $deployedCount++ 1309 | } 1310 | 1311 | # Create metadata 1312 | if ($metadataResource) { 1313 | $metadataDeployment = $metadataResource | Select-Object * -ExcludeProperty apiVersion, name 1314 | $metadataDeployment | Add-Member -NotePropertyName name -NotePropertyValue "workbook-$guid" -Force 1315 | 1316 | # Update parent ID to point to the new workbook 1317 | $workbookParentId = $metadataDeployment.properties.parentId -replace '/[^/]+$', "/$guid" 1318 | $metadataDeployment.properties | Add-Member -NotePropertyName parentId -NotePropertyValue $workbookParentId -Force 1319 | 1320 | $metadataPayload = $metadataDeployment | ConvertTo-Json -Depth 50 -EnumsAsStrings 1321 | 1322 | $metadataPath = "$baseUri/providers/Microsoft.SecurityInsights/metadata/workbook-$guid".Replace("https://management.azure.com", "") 1323 | $metadataPath += "?api-version=2023-05-01-preview" 1324 | 1325 | $metadataResult = Invoke-AzRestMethod -Path $metadataPath -Method PUT -Payload $metadataPayload 1326 | 1327 | if (-not ($metadataResult.StatusCode -in 200, 201)) { 1328 | Write-Warning "⚠️ Workbook created but metadata update failed: $displayName" 1329 | Write-Warning "Status: $($metadataResult.StatusCode)" 1330 | Write-Warning "Response: $($metadataResult.Content)" 1331 | } 1332 | } 1333 | } else { 1334 | Write-Error "❌ Failed to deploy workbook $displayName" 1335 | Write-Error "Status: $($workbookResult.StatusCode)" 1336 | Write-Error "Response: $($workbookResult.Content)" 1337 | $failedCount++ 1338 | } 1339 | } catch { 1340 | Write-Error "❌ Failed to deploy workbook $displayName : $($_.Exception.Message)" 1341 | $failedCount++ 1342 | } 1343 | } 1344 | 1345 | # Display summary 1346 | Write-Host "Workbook Deployment Summary:" -ForegroundColor Blue 1347 | Write-Host " - Installed: $deployedCount" -ForegroundColor Green 1348 | Write-Host " - Updated: $updatedCount" -ForegroundColor Cyan 1349 | Write-Host " - Skipped: $skippedCount" -ForegroundColor Yellow 1350 | if ($deprecatedCount -gt 0) { 1351 | Write-Host " - Deprecated: $deprecatedCount" -ForegroundColor Yellow 1352 | } 1353 | if ($failedCount -gt 0) { 1354 | Write-Host " - Failed: $failedCount" -ForegroundColor Red 1355 | } 1356 | } 1357 | 1358 | # Main execution block 1359 | 1360 | # First, deploy the requested solutions 1361 | $deploymentResults = Deploy-Solutions -ForceUpdate:$ForceSolutionUpdate -SkipUpdates:$SkipSolutionUpdates 1362 | 1363 | # Skip analytical rule deployment if requested 1364 | if ($SkipRuleDeployment) { 1365 | Write-Host "⏭️ Skipping analytical rules deployment as requested." -ForegroundColor Yellow 1366 | } else { 1367 | # Determine which solutions to deploy rules for 1368 | $solutionsForRules = @() 1369 | 1370 | # Add newly deployed solutions 1371 | if ($deploymentResults.Deployed -and $deploymentResults.Deployed.Count -gt 0) { 1372 | $solutionsForRules += $deploymentResults.Deployed 1373 | } 1374 | 1375 | # Add updated solutions 1376 | if ($deploymentResults.Updated -and $deploymentResults.Updated.Count -gt 0) { 1377 | $solutionsForRules += $deploymentResults.Updated 1378 | } 1379 | 1380 | # Add already installed solutions if ForceRuleDeployment is specified 1381 | if ($ForceRuleDeployment -and $deploymentResults.Installed -and $deploymentResults.Installed.Count -gt 0) { 1382 | $solutionsForRules += $deploymentResults.Installed 1383 | } 1384 | 1385 | # Deploy analytical rules if we have applicable solutions 1386 | if ($solutionsForRules.Count -gt 0) { 1387 | Deploy-AnalyticalRules -DeployedSolutions $solutionsForRules -SkipUpdates:$SkipRuleUpdates 1388 | } else { 1389 | Write-Host "⏭️ No solutions deployed or updated. Skipping analytical rules deployment." -ForegroundColor Yellow 1390 | } 1391 | } 1392 | 1393 | # Handle workbook deployment 1394 | if ($SkipWorkbookDeployment) { 1395 | Write-Host "Skipping workbook deployment as requested." -ForegroundColor Yellow 1396 | } else { 1397 | # Determine which solutions to deploy workbooks for 1398 | $solutionsForWorkbooks = @() 1399 | 1400 | # Add newly deployed solutions 1401 | if ($deploymentResults.Deployed -and $deploymentResults.Deployed.Count -gt 0) { 1402 | $solutionsForWorkbooks += $deploymentResults.Deployed 1403 | } 1404 | 1405 | # Add updated solutions 1406 | if ($deploymentResults.Updated -and $deploymentResults.Updated.Count -gt 0) { 1407 | $solutionsForWorkbooks += $deploymentResults.Updated 1408 | } 1409 | 1410 | # Add already installed solutions if ForceWorkbookDeployment is specified 1411 | if ($ForceWorkbookDeployment -and $deploymentResults.Installed -and $deploymentResults.Installed.Count -gt 0) { 1412 | $solutionsForWorkbooks += $deploymentResults.Installed 1413 | } 1414 | 1415 | # Deploy workbooks if we have applicable solutions 1416 | if ($solutionsForWorkbooks.Count -gt 0) { 1417 | Deploy-SolutionWorkbooks -DeployedSolutions $solutionsForWorkbooks -SkipUpdates:$SkipSolutionUpdates -DeployExistingWorkbooks:$ForceWorkbookDeployment 1418 | } else { 1419 | Write-Host "⏭️ No solutions to deploy workbooks for. Skipping workbook deployment." -ForegroundColor Yellow 1420 | } 1421 | } 1422 | --------------------------------------------------------------------------------