From 8041a0df7b8e83b03acb8ddaf2a63680c618aa53 Mon Sep 17 00:00:00 2001 From: TomerElall Date: Sun, 9 Nov 2025 11:38:53 +0200 Subject: [PATCH 01/45] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d1c4fbab0..f8f9cd31d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This repository provides samples of deployable Sentinel content as well as examp **Please note** that this repository contains sample content that is not intended to be used as or in the place of any real security content. The sole intention of this repository is to help demonstrate the capabilities of Microsoft Sentinel Repositories. # The Sample Content Folders -You can find a variety of supported content to use in your test deployments in the respective content folders of this repository. In addition, you can utilize the JSON or YAML to ARM scripts we've included in some folders (e.g. Detections, Hunting, and Workbooks) to convert your content files to the supported ARM format for repositories deployment. Please note that these scripts were used to convert some of the content in the [Azure Sentinel Community Repository](https://github.com/Azure/Azure-Sentinel) but have not been tested on all variations of content, please use with care. +You can find a variety of supported content to use in your test deployments in the respective content folders of this repository. In addition, you can utilize the JSON or YAML to ARM scripts we've included in some folders (e.g. Detections, Hunting and Workbooks) to convert your content files to the supported ARM format for repositories deployment. Please note that these scripts were used to convert some of the content in the [Azure Sentinel Community Repository](https://github.com/Azure/Azure-Sentinel) but have not been tested on all variations of content, please use with care. # Scaling your CICD pipeline ## Sentinel Deployment Configuration From 0d42f1ab63058b578f7d6d3110240a1753b005f6 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:08:36 +0000 Subject: [PATCH 02/45] Sentinel Content Deployment Script --- ...y-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 b/.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 new file mode 100644 index 000000000..2aaac50d2 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 @@ -0,0 +1,642 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 36b548d5655b2649d1b0957cb82e02442a525ad3 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:08:37 +0000 Subject: [PATCH 03/45] Workflow file for Sentinel-Deploy --- ...y-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml diff --git a/.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml b/.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml new file mode 100644 index 000000000..7d488790d --- /dev/null +++ b/.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml @@ -0,0 +1,128 @@ +name: Deploy Content to loganalyticstest [d5f786f5-ca11-46c1-84b8-d00c43eed593] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'loganalyticstest' + workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'AnalyticsRule,AutomationRule,HuntingQuery,Parser,Playbook' + branch: 'patch-1' + sourceControlId: 'd5f786f5-ca11-46c1-84b8-d00c43eed593' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_d5f786f5ca1146c184b8d00c43eed593 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_d5f786f5ca1146c184b8d00c43eed593 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_d5f786f5ca1146c184b8d00c43eed593 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_d5f786f5ca1146c184b8d00c43eed593 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_d5f786f5ca1146c184b8d00c43eed593 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_d5f786f5ca1146c184b8d00c43eed593 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_d5f786f5ca1146c184b8d00c43eed593 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_d5f786f5ca1146c184b8d00c43eed593 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_d5f786f5ca1146c184b8d00c43eed593 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 \ No newline at end of file From 8a58269b3d8c87d2b6685efb11bd9bf52659ce3f Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 13:47:05 +0000 Subject: [PATCH 04/45] Sentinel Content Deployment Script --- ...y-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 b/.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 new file mode 100644 index 000000000..2aaac50d2 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 @@ -0,0 +1,642 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 6c2935b24c397ea03425577d2ba24b397dc1a8c2 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 13:47:05 +0000 Subject: [PATCH 05/45] Workflow file for Sentinel-Deploy --- ...y-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml diff --git a/.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml b/.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml new file mode 100644 index 000000000..182caafb0 --- /dev/null +++ b/.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml @@ -0,0 +1,128 @@ +name: Deploy Content to amirtest2 [86f4b826-3d0c-4fa4-90c6-69aef12848ee] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'amirtest2' + workspaceId: '05af8f0f-b0bb-4b16-a838-a5b4fd73bc93' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'AnalyticsRule,AutomationRule,Parser' + branch: 'patch-1' + sourceControlId: '86f4b826-3d0c-4fa4-90c6-69aef12848ee' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_86f4b8263d0c4fa490c669aef12848ee }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_86f4b8263d0c4fa490c669aef12848ee }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_86f4b8263d0c4fa490c669aef12848ee }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_86f4b8263d0c4fa490c669aef12848ee }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_86f4b8263d0c4fa490c669aef12848ee }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_86f4b8263d0c4fa490c669aef12848ee }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_86f4b8263d0c4fa490c669aef12848ee }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_86f4b8263d0c4fa490c669aef12848ee }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_86f4b8263d0c4fa490c669aef12848ee }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 \ No newline at end of file From 01b869d6291fd00fc321191ab4cd1bd0b880c8d6 Mon Sep 17 00:00:00 2001 From: "azure-sentinel[bot]" <81647488+azure-sentinel[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 16:13:45 +0000 Subject: [PATCH 06/45] Sentinel Content Deployment Script --- ...y-e7383813-bbb9-4fd8-b4dc-743beac9ee13.ps1 | 642 ++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.ps1 b/.github/workflows/azure-sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.ps1 new file mode 100644 index 000000000..2aaac50d2 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.ps1 @@ -0,0 +1,642 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From b95ab23cc7af4db744c36e4df3623fc808bc7b73 Mon Sep 17 00:00:00 2001 From: "azure-sentinel[bot]" <81647488+azure-sentinel[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 16:13:46 +0000 Subject: [PATCH 07/45] Workflow file for Sentinel-Deploy --- ...y-e7383813-bbb9-4fd8-b4dc-743beac9ee13.yml | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.yml diff --git a/.github/workflows/sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.yml b/.github/workflows/sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.yml new file mode 100644 index 000000000..9c3ba1dfd --- /dev/null +++ b/.github/workflows/sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.yml @@ -0,0 +1,94 @@ +name: Deploy Content to tal-test [e7383813-bbb9-4fd8-b4dc-743beac9ee13] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'tal-test-rg' + workspaceName: 'tal-test' + workspaceId: '671511bf-526c-449e-956d-ce98e9d320c3' + directory: '${{ github.workspace }}' + cloudEnv: 'AzureCloud' + contentTypes: 'AnalyticsRule,AutomationRule,HuntingQuery,Parser,Playbook,Workbook' + branch: 'patch-1' + sourceControlId: 'e7383813-bbb9-4fd8-b4dc-743beac9ee13' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_e7383813bbb94fd8b4dc743beac9ee13 }} + tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_e7383813bbb94fd8b4dc743beac9ee13 }} + subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_e7383813bbb94fd8b4dc743beac9ee13 }} + environment: 'AzureCloud' + audience: api://AzureADTokenExchange + enable-AzPSSession: true + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/login@v2 + if: ${{ steps.login1.outcome=='failure' }} + with: + client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_e7383813bbb94fd8b4dc743beac9ee13 }} + tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_e7383813bbb94fd8b4dc743beac9ee13 }} + subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_e7383813bbb94fd8b4dc743beac9ee13 }} + environment: 'AzureCloud' + audience: api://AzureADTokenExchange + enable-AzPSSession: true + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/login@v2 + if: ${{ steps.login2.outcome=='failure' }} + with: + client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_e7383813bbb94fd8b4dc743beac9ee13 }} + tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_e7383813bbb94fd8b4dc743beac9ee13 }} + subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_e7383813bbb94fd8b4dc743beac9ee13 }} + environment: 'AzureCloud' + audience: api://AzureADTokenExchange + enable-AzPSSession: true + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-e7383813-bbb9-4fd8-b4dc-743beac9ee13.ps1 \ No newline at end of file From c51cb37dc58cc1bee65d26c7dc48d5564ffc45af Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:45:50 +0000 Subject: [PATCH 08/45] Remove deployment script file azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 --- ...y-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 | 642 ------------------ 1 file changed, 642 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 b/.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 deleted file mode 100644 index 2aaac50d2..000000000 --- a/.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 +++ /dev/null @@ -1,642 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From 6403dd2fca10a1b700bbedd6ffe2e838960009e9 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:45:50 +0000 Subject: [PATCH 09/45] Remove workflow sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml --- ...y-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml diff --git a/.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml b/.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml deleted file mode 100644 index 182caafb0..000000000 --- a/.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to amirtest2 [86f4b826-3d0c-4fa4-90c6-69aef12848ee] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'amirtest2' - workspaceId: '05af8f0f-b0bb-4b16-a838-a5b4fd73bc93' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'AnalyticsRule,AutomationRule,Parser' - branch: 'patch-1' - sourceControlId: '86f4b826-3d0c-4fa4-90c6-69aef12848ee' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_86f4b8263d0c4fa490c669aef12848ee }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_86f4b8263d0c4fa490c669aef12848ee }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_86f4b8263d0c4fa490c669aef12848ee }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_86f4b8263d0c4fa490c669aef12848ee }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_86f4b8263d0c4fa490c669aef12848ee }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_86f4b8263d0c4fa490c669aef12848ee }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_86f4b8263d0c4fa490c669aef12848ee }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_86f4b8263d0c4fa490c669aef12848ee }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_86f4b8263d0c4fa490c669aef12848ee }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-86f4b826-3d0c-4fa4-90c6-69aef12848ee.ps1 \ No newline at end of file From 7c7990bd1c57206e79f67be3b3aa477bf8921cea Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:03:05 +0000 Subject: [PATCH 10/45] Remove deployment script file azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 --- ...y-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 | 642 ------------------ 1 file changed, 642 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 b/.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 deleted file mode 100644 index 2aaac50d2..000000000 --- a/.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 +++ /dev/null @@ -1,642 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From 605e6bae3624f1399d350c0da0ce591062f8b0e7 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:03:05 +0000 Subject: [PATCH 11/45] Remove workflow sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml --- ...y-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml diff --git a/.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml b/.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml deleted file mode 100644 index 7d488790d..000000000 --- a/.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to loganalyticstest [d5f786f5-ca11-46c1-84b8-d00c43eed593] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'loganalyticstest' - workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'AnalyticsRule,AutomationRule,HuntingQuery,Parser,Playbook' - branch: 'patch-1' - sourceControlId: 'd5f786f5-ca11-46c1-84b8-d00c43eed593' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_d5f786f5ca1146c184b8d00c43eed593 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_d5f786f5ca1146c184b8d00c43eed593 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_d5f786f5ca1146c184b8d00c43eed593 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_d5f786f5ca1146c184b8d00c43eed593 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_d5f786f5ca1146c184b8d00c43eed593 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_d5f786f5ca1146c184b8d00c43eed593 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_d5f786f5ca1146c184b8d00c43eed593 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_d5f786f5ca1146c184b8d00c43eed593 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_d5f786f5ca1146c184b8d00c43eed593 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-d5f786f5-ca11-46c1-84b8-d00c43eed593.ps1 \ No newline at end of file From e1516b9b5ea05ab37ca276391e244bdeb77d69dd Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:03:41 +0000 Subject: [PATCH 12/45] Sentinel Content Deployment Script --- ...y-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 b/.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From fef2909f242dfcab1d5c621abb58941a7a9ee802 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:03:41 +0000 Subject: [PATCH 13/45] Workflow file for Sentinel-Deploy --- ...y-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml diff --git a/.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml b/.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml new file mode 100644 index 000000000..40c83092a --- /dev/null +++ b/.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml @@ -0,0 +1,128 @@ +name: Deploy Content to loganalyticstest [6d7ed1bd-c545-42de-8a46-22f41fa1207e] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'loganalyticstest' + workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'AnalyticsRule,CustomDetection' + branch: 'patch-1' + sourceControlId: '6d7ed1bd-c545-42de-8a46-22f41fa1207e' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_6d7ed1bdc54542de8a4622f41fa1207e }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_6d7ed1bdc54542de8a4622f41fa1207e }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_6d7ed1bdc54542de8a4622f41fa1207e }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 \ No newline at end of file From b2e65521a1380423598af0540286c30f37ce8060 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:12:43 +0000 Subject: [PATCH 14/45] Remove deployment script file azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 --- ...y-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 b/.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From ffe0f3fce30f4cd5ba53844a649954c0457fe547 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:12:43 +0000 Subject: [PATCH 15/45] Remove workflow sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml --- ...y-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml diff --git a/.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml b/.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml deleted file mode 100644 index 40c83092a..000000000 --- a/.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to loganalyticstest [6d7ed1bd-c545-42de-8a46-22f41fa1207e] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'loganalyticstest' - workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'AnalyticsRule,CustomDetection' - branch: 'patch-1' - sourceControlId: '6d7ed1bd-c545-42de-8a46-22f41fa1207e' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_6d7ed1bdc54542de8a4622f41fa1207e }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_6d7ed1bdc54542de8a4622f41fa1207e }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_6d7ed1bdc54542de8a4622f41fa1207e }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_6d7ed1bdc54542de8a4622f41fa1207e }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-6d7ed1bd-c545-42de-8a46-22f41fa1207e.ps1 \ No newline at end of file From 0ed9c9f00cccb74c00109cbc45d8307b8ff2ff05 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:14:09 +0000 Subject: [PATCH 16/45] Sentinel Content Deployment Script --- ...y-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 b/.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 2e9512ef2c5893c7cc079e117a0db8fa6eb7ad5d Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:14:09 +0000 Subject: [PATCH 17/45] Workflow file for Sentinel-Deploy --- ...y-18b104a2-a339-45ef-8dcc-880f13e52087.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml diff --git a/.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml b/.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml new file mode 100644 index 000000000..408c95fee --- /dev/null +++ b/.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml @@ -0,0 +1,128 @@ +name: Deploy Content to loganalyticstest [18b104a2-a339-45ef-8dcc-880f13e52087] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'loganalyticstest' + workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'CustomDetection,AutomationRule,HuntingQuery' + branch: 'patch-1' + sourceControlId: '18b104a2-a339-45ef-8dcc-880f13e52087' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_18b104a2a33945ef8dcc880f13e52087 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_18b104a2a33945ef8dcc880f13e52087 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_18b104a2a33945ef8dcc880f13e52087 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_18b104a2a33945ef8dcc880f13e52087 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_18b104a2a33945ef8dcc880f13e52087 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_18b104a2a33945ef8dcc880f13e52087 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_18b104a2a33945ef8dcc880f13e52087 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_18b104a2a33945ef8dcc880f13e52087 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_18b104a2a33945ef8dcc880f13e52087 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 \ No newline at end of file From ffa0e0a9a37a0568c4dd6d586e1c558780e41212 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:14:17 +0000 Subject: [PATCH 18/45] Remove deployment script file azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 --- ...y-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 b/.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From b981bc2bb76e7374adccc256afdb823967223ef2 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:14:18 +0000 Subject: [PATCH 19/45] Remove workflow sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml --- ...y-18b104a2-a339-45ef-8dcc-880f13e52087.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml diff --git a/.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml b/.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml deleted file mode 100644 index 408c95fee..000000000 --- a/.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to loganalyticstest [18b104a2-a339-45ef-8dcc-880f13e52087] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'loganalyticstest' - workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'CustomDetection,AutomationRule,HuntingQuery' - branch: 'patch-1' - sourceControlId: '18b104a2-a339-45ef-8dcc-880f13e52087' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_18b104a2a33945ef8dcc880f13e52087 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_18b104a2a33945ef8dcc880f13e52087 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_18b104a2a33945ef8dcc880f13e52087 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_18b104a2a33945ef8dcc880f13e52087 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_18b104a2a33945ef8dcc880f13e52087 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_18b104a2a33945ef8dcc880f13e52087 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_18b104a2a33945ef8dcc880f13e52087 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_18b104a2a33945ef8dcc880f13e52087 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_18b104a2a33945ef8dcc880f13e52087 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-18b104a2-a339-45ef-8dcc-880f13e52087.ps1 \ No newline at end of file From 81b0dbcd8fc4e0dc2e026f7fb51e6be9f4026488 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:20:13 +0000 Subject: [PATCH 20/45] Sentinel Content Deployment Script --- ...y-289474dd-cb40-416a-b831-ac14d80fb464.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 b/.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 89054efb849f2d511a5585daa2c3f4057df8ec53 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:20:14 +0000 Subject: [PATCH 21/45] Workflow file for Sentinel-Deploy --- ...y-289474dd-cb40-416a-b831-ac14d80fb464.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml diff --git a/.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml b/.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml new file mode 100644 index 000000000..c1097d9ce --- /dev/null +++ b/.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml @@ -0,0 +1,128 @@ +name: Deploy Content to loganalyticstest [289474dd-cb40-416a-b831-ac14d80fb464] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'loganalyticstest' + workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'CustomDetection,AutomationRule,HuntingQuery' + branch: 'patch-1' + sourceControlId: '289474dd-cb40-416a-b831-ac14d80fb464' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_289474ddcb40416ab831ac14d80fb464 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_289474ddcb40416ab831ac14d80fb464 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_289474ddcb40416ab831ac14d80fb464 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_289474ddcb40416ab831ac14d80fb464 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_289474ddcb40416ab831ac14d80fb464 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_289474ddcb40416ab831ac14d80fb464 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_289474ddcb40416ab831ac14d80fb464 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_289474ddcb40416ab831ac14d80fb464 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_289474ddcb40416ab831ac14d80fb464 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 \ No newline at end of file From 5e0864ac36b0f0f7640aa92d2259cbe302c1f7b5 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:21:10 +0000 Subject: [PATCH 22/45] Remove deployment script file azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 --- ...y-289474dd-cb40-416a-b831-ac14d80fb464.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 b/.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From 01948e408e4a758c3791b8bdf093960da78cffd6 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:21:11 +0000 Subject: [PATCH 23/45] Remove workflow sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml --- ...y-289474dd-cb40-416a-b831-ac14d80fb464.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml diff --git a/.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml b/.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml deleted file mode 100644 index c1097d9ce..000000000 --- a/.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to loganalyticstest [289474dd-cb40-416a-b831-ac14d80fb464] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'loganalyticstest' - workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'CustomDetection,AutomationRule,HuntingQuery' - branch: 'patch-1' - sourceControlId: '289474dd-cb40-416a-b831-ac14d80fb464' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_289474ddcb40416ab831ac14d80fb464 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_289474ddcb40416ab831ac14d80fb464 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_289474ddcb40416ab831ac14d80fb464 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_289474ddcb40416ab831ac14d80fb464 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_289474ddcb40416ab831ac14d80fb464 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_289474ddcb40416ab831ac14d80fb464 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_289474ddcb40416ab831ac14d80fb464 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_289474ddcb40416ab831ac14d80fb464 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_289474ddcb40416ab831ac14d80fb464 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-289474dd-cb40-416a-b831-ac14d80fb464.ps1 \ No newline at end of file From 1c4eff12aa65db7d745edd03fd0ed678b1764c26 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:21:39 +0000 Subject: [PATCH 24/45] Sentinel Content Deployment Script --- ...y-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 b/.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 81436462c83f1e329f09645ab47f37e6dd3a4b63 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:21:40 +0000 Subject: [PATCH 25/45] Workflow file for Sentinel-Deploy --- ...y-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml diff --git a/.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml b/.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml new file mode 100644 index 000000000..e5536b940 --- /dev/null +++ b/.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml @@ -0,0 +1,128 @@ +name: Deploy Content to loganalyticstest [ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'loganalyticstest' + workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'AnalyticsRule,AutomationRule,CustomDetection,Playbook' + branch: 'patch-1' + sourceControlId: 'ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 \ No newline at end of file From d11c280eee49b14f4fc4eb63bccbf79bba937d81 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:22:02 +0000 Subject: [PATCH 26/45] Remove deployment script file azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 --- ...y-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 b/.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From 7d3b29c7e620107c449da070532569d283431a83 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:22:02 +0000 Subject: [PATCH 27/45] Remove workflow sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml --- ...y-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml diff --git a/.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml b/.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml deleted file mode 100644 index e5536b940..000000000 --- a/.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to loganalyticstest [ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'loganalyticstest' - workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'AnalyticsRule,AutomationRule,CustomDetection,Playbook' - branch: 'patch-1' - sourceControlId: 'ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_ac8f0cce1dfe44faaa5ba34c9b19e56c }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-ac8f0cce-1dfe-44fa-aa5b-a34c9b19e56c.ps1 \ No newline at end of file From e66d0db319b1707bb119a43b41c59eb2d09ef763 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:22:30 +0000 Subject: [PATCH 28/45] Sentinel Content Deployment Script --- ...y-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 b/.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From a6376807afba4741abc85a089b68796965e583fb Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:22:31 +0000 Subject: [PATCH 29/45] Workflow file for Sentinel-Deploy --- ...y-f7671465-fb49-4c01-95e4-df980ca4700b.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml diff --git a/.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml b/.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml new file mode 100644 index 000000000..42fae12e4 --- /dev/null +++ b/.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml @@ -0,0 +1,128 @@ +name: Deploy Content to loganalyticstest [f7671465-fb49-4c01-95e4-df980ca4700b] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'loganalyticstest' + workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'CustomDetection' + branch: 'patch-1' + sourceControlId: 'f7671465-fb49-4c01-95e4-df980ca4700b' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_f7671465fb494c0195e4df980ca4700b }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_f7671465fb494c0195e4df980ca4700b }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_f7671465fb494c0195e4df980ca4700b }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_f7671465fb494c0195e4df980ca4700b }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_f7671465fb494c0195e4df980ca4700b }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_f7671465fb494c0195e4df980ca4700b }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_f7671465fb494c0195e4df980ca4700b }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_f7671465fb494c0195e4df980ca4700b }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_f7671465fb494c0195e4df980ca4700b }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 \ No newline at end of file From 7d0c9d839e8579f8294efb93bb9ab487fe703cc1 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:50:46 +0000 Subject: [PATCH 30/45] Sentinel Content Deployment Script --- ...y-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 b/.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 4b7a20fd7615ce562331e6faca3666d55fc4676a Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:50:47 +0000 Subject: [PATCH 31/45] Workflow file for Sentinel-Deploy --- ...y-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml diff --git a/.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml b/.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml new file mode 100644 index 000000000..4e1c37e96 --- /dev/null +++ b/.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml @@ -0,0 +1,128 @@ +name: Deploy Content to testrolemtp [3c4dc2e9-96d0-47e6-8d81-57579591cfb4] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'testrolemtp' + workspaceId: 'f2aed523-c473-4a2f-a94a-010ab6448743' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'AnalyticsRule,CustomDetection' + branch: 'patch-1' + sourceControlId: '3c4dc2e9-96d0-47e6-8d81-57579591cfb4' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c4dc2e996d047e68d8157579591cfb4 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c4dc2e996d047e68d8157579591cfb4 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c4dc2e996d047e68d8157579591cfb4 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c4dc2e996d047e68d8157579591cfb4 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c4dc2e996d047e68d8157579591cfb4 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c4dc2e996d047e68d8157579591cfb4 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c4dc2e996d047e68d8157579591cfb4 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c4dc2e996d047e68d8157579591cfb4 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c4dc2e996d047e68d8157579591cfb4 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 \ No newline at end of file From 56d6602dc1dccea4742e0147d7de36d546527e0e Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:50:56 +0000 Subject: [PATCH 32/45] Remove deployment script file azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 --- ...y-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 b/.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From b7e37bef37d1dafafb70c77f010f91d56479b525 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:50:56 +0000 Subject: [PATCH 33/45] Remove workflow sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml --- ...y-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml diff --git a/.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml b/.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml deleted file mode 100644 index 4e1c37e96..000000000 --- a/.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to testrolemtp [3c4dc2e9-96d0-47e6-8d81-57579591cfb4] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'testrolemtp' - workspaceId: 'f2aed523-c473-4a2f-a94a-010ab6448743' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'AnalyticsRule,CustomDetection' - branch: 'patch-1' - sourceControlId: '3c4dc2e9-96d0-47e6-8d81-57579591cfb4' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c4dc2e996d047e68d8157579591cfb4 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c4dc2e996d047e68d8157579591cfb4 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c4dc2e996d047e68d8157579591cfb4 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c4dc2e996d047e68d8157579591cfb4 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c4dc2e996d047e68d8157579591cfb4 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c4dc2e996d047e68d8157579591cfb4 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c4dc2e996d047e68d8157579591cfb4 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c4dc2e996d047e68d8157579591cfb4 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c4dc2e996d047e68d8157579591cfb4 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-3c4dc2e9-96d0-47e6-8d81-57579591cfb4.ps1 \ No newline at end of file From 10713057816dae352e38adefdd383ff3e2760164 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:52:06 +0000 Subject: [PATCH 34/45] Sentinel Content Deployment Script --- ...y-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 b/.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 84bf5be620da95bc59f7c27e8a5e9d12481dad6a Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:52:07 +0000 Subject: [PATCH 35/45] Workflow file for Sentinel-Deploy --- ...y-3c07ba4c-6ec3-4252-b274-49a2832449da.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml diff --git a/.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml b/.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml new file mode 100644 index 000000000..09babcc48 --- /dev/null +++ b/.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml @@ -0,0 +1,128 @@ +name: Deploy Content to testrolemtp [3c07ba4c-6ec3-4252-b274-49a2832449da] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'testrolemtp' + workspaceId: 'f2aed523-c473-4a2f-a94a-010ab6448743' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'AnalyticsRule,CustomDetection' + branch: 'patch-1' + sourceControlId: '3c07ba4c-6ec3-4252-b274-49a2832449da' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c07ba4c6ec34252b27449a2832449da }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c07ba4c6ec34252b27449a2832449da }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c07ba4c6ec34252b27449a2832449da }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c07ba4c6ec34252b27449a2832449da }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c07ba4c6ec34252b27449a2832449da }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c07ba4c6ec34252b27449a2832449da }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c07ba4c6ec34252b27449a2832449da }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c07ba4c6ec34252b27449a2832449da }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c07ba4c6ec34252b27449a2832449da }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 \ No newline at end of file From e7ed354907dace9aca7b0fe366126e1d822bf829 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:59:23 +0000 Subject: [PATCH 36/45] Remove deployment script file azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 --- ...y-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 b/.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From 8024498f3900168fccb374d37e369b99c20fe8b7 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:59:23 +0000 Subject: [PATCH 37/45] Remove workflow sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml --- ...y-3c07ba4c-6ec3-4252-b274-49a2832449da.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml diff --git a/.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml b/.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml deleted file mode 100644 index 09babcc48..000000000 --- a/.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to testrolemtp [3c07ba4c-6ec3-4252-b274-49a2832449da] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'testrolemtp' - workspaceId: 'f2aed523-c473-4a2f-a94a-010ab6448743' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'AnalyticsRule,CustomDetection' - branch: 'patch-1' - sourceControlId: '3c07ba4c-6ec3-4252-b274-49a2832449da' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c07ba4c6ec34252b27449a2832449da }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c07ba4c6ec34252b27449a2832449da }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c07ba4c6ec34252b27449a2832449da }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c07ba4c6ec34252b27449a2832449da }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c07ba4c6ec34252b27449a2832449da }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c07ba4c6ec34252b27449a2832449da }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_3c07ba4c6ec34252b27449a2832449da }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_3c07ba4c6ec34252b27449a2832449da }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_3c07ba4c6ec34252b27449a2832449da }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-3c07ba4c-6ec3-4252-b274-49a2832449da.ps1 \ No newline at end of file From b1b9740277f0b8cb925b0797e4f4e2bbb825b379 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:59:52 +0000 Subject: [PATCH 38/45] Sentinel Content Deployment Script --- ...y-70b27887-856e-4f03-a7f0-2cec8af84042.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.ps1 b/.github/workflows/azure-sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 5accf88a714f26658f5554e24d548e698c006ee2 Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:59:53 +0000 Subject: [PATCH 39/45] Workflow file for Sentinel-Deploy --- ...y-70b27887-856e-4f03-a7f0-2cec8af84042.yml | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.yml diff --git a/.github/workflows/sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.yml b/.github/workflows/sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.yml new file mode 100644 index 000000000..215008ad2 --- /dev/null +++ b/.github/workflows/sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.yml @@ -0,0 +1,128 @@ +name: Deploy Content to testrolemtp [70b27887-856e-4f03-a7f0-2cec8af84042] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'loganalyticstest' + workspaceName: 'testrolemtp' + workspaceId: 'f2aed523-c473-4a2f-a94a-010ab6448743' + directory: '${{ github.workspace }}' + cloudEnv: 'AzurePPE' + contentTypes: 'CustomDetection' + branch: 'patch-1' + sourceControlId: '70b27887-856e-4f03-a7f0-2cec8af84042' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_70b27887856e4f03a7f02cec8af84042 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_70b27887856e4f03a7f02cec8af84042 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_70b27887856e4f03a7f02cec8af84042 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_70b27887856e4f03a7f02cec8af84042 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_70b27887856e4f03a7f02cec8af84042 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_70b27887856e4f03a7f02cec8af84042 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Add-AzEnvironment ` + -Name AzurePPE ` + -ActiveDirectoryEndpoint https://login.windows-ppe.net ` + -ResourceManagerEndpoint https://management.azure.com/ ` + -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` + -GraphEndpoint https://graph.ppe.windows.net/ | out-null; + $oidcTokenParams = @{ + Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL + Body = @{ + audience = 'api://AzureADTokenExchange' + } + Authentication = 'Bearer' + Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText + } + $IdToken = (Invoke-RestMethod @oidcTokenParams).value + Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_70b27887856e4f03a7f02cec8af84042 }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_70b27887856e4f03a7f02cec8af84042 }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_70b27887856e4f03a7f02cec8af84042 }} -Environment AzurePPE -FederatedToken $IdToken | out-null; + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-70b27887-856e-4f03-a7f0-2cec8af84042.ps1 \ No newline at end of file From cdd39c2d36737c816bac6bde2f1eed3e74b3e53e Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:22:40 +0000 Subject: [PATCH 40/45] Remove deployment script file azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 --- ...y-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 b/.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From c3ed2a0d17b9b26349ac1bd6996006e29baa7a4e Mon Sep 17 00:00:00 2001 From: "azure-sentinel-dev[bot]" <81646318+azure-sentinel-dev[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:22:41 +0000 Subject: [PATCH 41/45] Remove workflow sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml --- ...y-f7671465-fb49-4c01-95e4-df980ca4700b.yml | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml diff --git a/.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml b/.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml deleted file mode 100644 index 42fae12e4..000000000 --- a/.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Content to loganalyticstest [f7671465-fb49-4c01-95e4-df980ca4700b] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'loganalyticstest' - workspaceName: 'loganalyticstest' - workspaceId: '7ec1a547-4b8a-45ad-b9c6-d8219a93a8b4' - directory: '${{ github.workspace }}' - cloudEnv: 'AzurePPE' - contentTypes: 'CustomDetection' - branch: 'patch-1' - sourceControlId: 'f7671465-fb49-4c01-95e4-df980ca4700b' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_f7671465fb494c0195e4df980ca4700b }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_f7671465fb494c0195e4df980ca4700b }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_f7671465fb494c0195e4df980ca4700b }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_f7671465fb494c0195e4df980ca4700b }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_f7671465fb494c0195e4df980ca4700b }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_f7671465fb494c0195e4df980ca4700b }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - Add-AzEnvironment ` - -Name AzurePPE ` - -ActiveDirectoryEndpoint https://login.windows-ppe.net ` - -ResourceManagerEndpoint https://management.azure.com/ ` - -ActiveDirectoryServiceEndpointResourceId https://management.core.windows.net ` - -GraphEndpoint https://graph.ppe.windows.net/ | out-null; - $oidcTokenParams = @{ - Uri = $env:ACTIONS_ID_TOKEN_REQUEST_URL - Body = @{ - audience = 'api://AzureADTokenExchange' - } - Authentication = 'Bearer' - Token = $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN | ConvertTo-SecureString -AsPlainText - } - $IdToken = (Invoke-RestMethod @oidcTokenParams).value - Connect-AzAccount -ServicePrincipal -Tenant ${{ secrets.AZURE_SENTINEL_TENANTID_f7671465fb494c0195e4df980ca4700b }} -Subscription ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_f7671465fb494c0195e4df980ca4700b }} -ApplicationId ${{ secrets.AZURE_SENTINEL_CLIENTID_f7671465fb494c0195e4df980ca4700b }} -Environment AzurePPE -FederatedToken $IdToken | out-null; - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-f7671465-fb49-4c01-95e4-df980ca4700b.ps1 \ No newline at end of file From ad5e8de5c920483495d607d19a6a9962ddc11a1b Mon Sep 17 00:00:00 2001 From: "azure-sentinel[bot]" <81647488+azure-sentinel[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:08:49 +0000 Subject: [PATCH 42/45] Sentinel Content Deployment Script --- ...y-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 .github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 b/.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 new file mode 100644 index 000000000..a01e7a643 --- /dev/null +++ b/.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 @@ -0,0 +1,650 @@ +## Globals ## +$CloudEnv = $Env:cloudEnv +$ResourceGroupName = $Env:resourceGroupName +$WorkspaceName = $Env:workspaceName +$WorkspaceId = $Env:workspaceId +$Directory = $Env:directory +$contentTypes = $Env:contentTypes +$contentTypeMapping = @{ + "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); + "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); + "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); + "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); + "Workbook"=@("Microsoft.Insights/workbooks"); + "CustomDetection"=@("Microsoft.XDR/customDetections"); +} +$sourceControlId = $Env:sourceControlId +$rootDirectory = $Env:rootDirectory +$githubAuthToken = $Env:githubAuthToken +$githubRepository = $Env:GITHUB_REPOSITORY +$branchName = $Env:branch +$smartDeployment = $Env:smartDeployment +$newResourceBranch = $branchName + "-sentinel-deployment" +$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" +$configPath = "$rootDirectory\sentinel-deployment.config" +$global:localCsvTablefinal = @{} +$global:updatedCsvTable = @{} +$global:parameterFileMapping = @{} +$global:prioritizedContentFiles = @() +$global:excludeContentFiles = @() + +$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' +$namePattern = '([-\w\._\(\)]+)' +$sentinelResourcePatterns = @{ + "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" + "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" + "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" + "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" + "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" + "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" +} + +if ([string]::IsNullOrEmpty($contentTypes)) { + $contentTypes = "AnalyticsRule" +} + +$metadataFilePath = "metadata.json" +@" +{ + "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "parentResourceId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sourceControlId": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "customVersion": { + "type": "string" + } + }, + "variables": { + "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", + "properties": { + "parentId": "[parameters('parentResourceId')]", + "kind": "[parameters('kind')]", + "customVersion": "[parameters('customVersion')]", + "source": { + "kind": "SourceRepository", + "name": "Repositories", + "sourceId": "[parameters('sourceControlId')]" + } + } + } + ] +} +"@ | Out-File -FilePath $metadataFilePath + +$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } +$MaxRetries = 3 +$secondsBetweenAttempts = 5 + +#Converts hashtable to string that can be set as content when pushing csv file +function ConvertTableToString { + $output = "FileName, CommitSha`n" + $global:updatedCsvTable.GetEnumerator() | ForEach-Object { + $key = RelativePathWithBackslash $_.Key + $output += "{0},{1}`n" -f $key, $_.Value + } + return $output +} + +$header = @{ + "authorization" = "Bearer $githubAuthToken" +} + +#Gets all files and commit shas using Get Trees API +function GetGithubTree { + $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 + $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" + $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 + return $getTreeResponse +} + +#Creates a table using the reponse from the tree api, creates a table +function GetCommitShaTable($getTreeResponse) { + $shaTable = @{} + $supportedExtensions = @(".json", ".bicep", ".bicepparam"); + $getTreeResponse.tree | ForEach-Object { + $truePath = AbsolutePathWithSlash $_.path + if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) + { + $shaTable.Add($truePath, $_.sha) + } + } + return $shaTable +} + +function PushCsvToRepo() { + $content = ConvertTableToString + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 0) { + git switch --orphan $newResourceBranch + git commit --allow-empty -m "Initial commit on orphan branch" + git push -u origin $newResourceBranch + New-Item -ItemType "directory" -Path ".sentinel" + } else { + git fetch > $null + git checkout $newResourceBranch + } + + Write-Output $content > $relativeCsvPath + git add $relativeCsvPath + git commit -m "Modified tracking table" + git push -u origin $newResourceBranch + git checkout $branchName +} + +function ReadCsvToTable { + $csvTable = Import-Csv -Path $csvPath + $HashTable=@{} + foreach($r in $csvTable) + { + $key = AbsolutePathWithSlash $r.FileName + $HashTable[$key]=$r.CommitSha + } + return $HashTable +} + +function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { + $Stoploop = $false + $retryCount = 0 + do { + try { + $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes + $Stoploop = $true + } + catch { + if ($retryCount -gt $maxRetries) { + Write-Host "[Error] API call failed after $retryCount retries: $_" + $Stoploop = $true + } + else { + Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." + Start-Sleep -Seconds 5 + $retryCount = $retryCount + 1 + } + } + } + While ($Stoploop -eq $false) + return $result +} + +function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { + $deploymentInfo = $null + try { + $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore + } + catch { + Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" + return + } + $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { + $resource = $_.TargetResource + $sentinelContentKinds = GetContentKinds $resource + if ($sentinelContentKinds.Count -gt 0) { + $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject + + if ($contentKind -eq "CustomDetection") { + Write-Host "[Info] Skipping metadata deployment for CustomDetection content." + return + } + + $contentId = $resource.Split("/")[-1] + $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $false + $currentAttempt = 0 + + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` + -parentResourceId $resource ` + -kind $contentKind ` + -contentId $contentId ` + -sourceControlId $sourceControlId ` + -workspace $workspaceName ` + -customVersion $metadataCustomVersion ` + -ErrorAction Stop | Out-Host + Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable "md-$deploymentName")) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" + } + } + } + } + } + } +} + +function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ + $customVersion = $templateType + "-" + $paramFileType + if($containsWorkspaceParam){ + $customVersion += "-WorkspaceParam" + } + if($smartDeployment -eq "true"){ + $customVersion += "-SmartTracking" + } + return $customVersion +} + +function GetContentKinds($resource) { + return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } +} + +function ToContentKind($contentKinds, $resource, $templateObject) { + if ($contentKinds.Count -eq 1) { + return $contentKinds + } + if ($null -ne $resource -and $resource.Contains('savedSearches')) { + if ($templateObject.resources.properties.Category -eq "Hunting Queries") { + return "HuntingQuery" + } + return "Parser" + } + return $null +} + +function IsValidTemplate($path, $templateObject, $parameterFile) { + Try { + if (DoesContainWorkspaceParam $templateObject) { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName + } + else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName + } + } + else { + if ($parameterFile) { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile + } else { + Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path + } + } + + return $true + } + Catch { + Write-Host "[Warning] The file $path is not valid: $_" + return $false + } +} + +function IsRetryable($deploymentName) { + $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" + Try { + $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop + return $retryableStatusCodes -contains $deploymentResult.StatusCode + } + Catch { + return $false + } +} + +function IsValidResourceType($template) { + try { + $isAllowedResources = $true + $template.resources | ForEach-Object { + $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources + } + } + catch { + Write-Host "[Error] Failed to check valid resource type." + $isAllowedResources = $false + } + return $isAllowedResources +} + +function DoesContainWorkspaceParam($templateObject) { + $templateObject.parameters.PSobject.Properties.Name -contains "workspace" +} + +function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { + Write-Host "[Info] Deploying $path with deployment name $deploymentName" + + $isValid = IsValidTemplate $path $templateObject $parameterFile + if (-not $isValid) { + Write-Host "[Error] Not deploying $path since the template is not valid" + return $false + } + $isSuccess = $false + $currentAttempt = 0 + While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) + { + $currentAttempt ++ + Try + { + Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" + $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} + $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject + if ($containsWorkspaceParam) + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host + } + } + else + { + if ($parameterFile) { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host + } + else + { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host + } + } + AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam + + $isSuccess = $true + } + Catch [Exception] + { + $err = $_ + if (-not (IsRetryable $deploymentName)) + { + Write-Host "[Warning] Failed to deploy $path with error: $err" + break + } + else + { + if ($currentAttempt -le $MaxRetries) + { + Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." + Start-Sleep -Seconds $secondsBetweenAttempts + } + else + { + Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" + } + } + } + } + return $isSuccess +} + +function GenerateDeploymentName() { + $randomId = [guid]::NewGuid() + return "Sentinel_Deployment_$randomId" +} + +#Load deployment configuration +function LoadDeploymentConfig() { + Write-Host "[Info] load the deployment configuration from [$configPath]" + $global:parameterFileMapping = @{} + $global:prioritizedContentFiles = @() + $global:excludeContentFiles = @() + try { + if (Test-Path $configPath) { + $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json + $parameterFileMappings = @{} + if ($deployment_config.parameterfilemappings) { + $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } + } + $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) + if ($null -ne $key) { + $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } + } + if ($deployment_config.prioritizedcontentfiles) { + $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles + } + $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles + if ($deployment_config.excludecontentfiles) { + $excludeList = $excludeList + $deployment_config.excludecontentfiles + } + $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } + } + } + catch { + Write-Host "[Warning] An error occurred while trying to load deployment configuration." + Write-Host "Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function filterContentFile($fullPath) { + $temp = RelativePathWithBackslash $fullPath + return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} +} + +function RelativePathWithBackslash($absolutePath) { + return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") +} + +function AbsolutePathWithSlash($relativePath) { + return Join-Path -Path $rootDirectory -ChildPath $relativePath +} + +#resolve parameter file name, return $null if there is none. +function GetParameterFile($path) { + if ($path.Length -eq 0) { + return $null + } + + $index = RelativePathWithBackslash $path + $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) + if ($key) { + $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] + if (Test-Path $mappedParameterFile) { + return $mappedParameterFile + } + } + + $extension = [System.IO.Path]::GetExtension($path) + if ($extension -ne ".json" -and $extension -ne ".bicep") { + return $null + } + + $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) + + # Check for workspace-specific parameter file + if ($extension -eq ".bicep") { + $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + } + + $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" + if (Test-Path $workspaceParameterFile) { + return $workspaceParameterFile + } + + # Check for parameter file + if ($extension -eq ".bicep") { + $defaultParameterFile = $parameterFilePrefix + ".bicepparam" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + } + + $defaultParameterFile = $parameterFilePrefix + ".parameters.json" + Write-Host "Default parameter file: $defaultParameterFile" + if (Test-Path $defaultParameterFile) { + return $defaultParameterFile + } + + return $null +} + +function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { + Write-Host "Starting Deployment for Files in path: $Directory" + if (Test-Path -Path $Directory) + { + $totalFiles = 0; + $totalFailed = 0; + $iterationList = @() + $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } + Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | + Where-Object { $null -eq ( filterContentFile $_.FullName ) } | + Select-Object -Property FullName | + ForEach-Object { $iterationList += $_.FullName } + $iterationList | ForEach-Object { + $path = $_ + Write-Host "[Info] Try to deploy $path" + if (-not (Test-Path $path)) { + Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." + return + } + + if ($path -like "*.bicep") { + $templateType = "Bicep" + $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json + } else { + $templateType = "ARM" + $templateObject = Get-Content $path | Out-String | ConvertFrom-Json + } + + if (-not (IsValidResourceType $templateObject)) + { + Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." + return + } + $parameterFile = GetParameterFile $path + $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType + if ($result.isSuccess -eq $false) { + $totalFailed++ + } + if (-not $result.skip) { + $totalFiles++ + } + if ($result.isSuccess -or $result.skip) { + $global:updatedCsvTable[$path] = $remoteShaTable[$path] + if ($parameterFile) { + $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] + } + } + } + PushCsvToRepo + if ($totalFiles -gt 0 -and $totalFailed -gt 0) + { + $err = "$totalFailed of $totalFiles deployments failed." + Throw $err + } + } + else + { + Write-Output "[Warning] $Directory not found. nothing to deploy" + } +} + +function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { + try { + $skip = $false + $isSuccess = $null + if (!$fullDeploymentFlag) { + $existingSha = $global:localCsvTablefinal[$path] + $remoteSha = $remoteShaTable[$path] + $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) + if ($skip -and $parameterFile) { + $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] + $remoteShaForParameterFile = $remoteShaTable[$parameterFile] + $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) + } + } + if (!$skip) { + $deploymentName = GenerateDeploymentName + $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType + } + return @{ + skip = $skip + isSuccess = $isSuccess + } + } + catch { + Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" + Write-Host $_.ScriptStackTrace + } +} + +function TryGetCsvFile { + if (Test-Path $csvPath) { + $global:localCsvTablefinal = ReadCsvToTable + Remove-Item -Path $csvPath + git add $csvPath + git commit -m "Removed tracking file and moved to new sentinel created branch" + git push origin $branchName + } + + $relativeCsvPath = RelativePathWithBackslash $csvPath + $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l + + if ($resourceBranchExists -eq 1) { + git fetch > $null + git checkout $newResourceBranch + + if (Test-Path $relativeCsvPath) { + $global:localCsvTablefinal = ReadCsvToTable + } + git checkout $branchName + } +} + +function main() { + git config --global user.email "donotreply@microsoft.com" + git config --global user.name "Sentinel" + + TryGetCsvFile + LoadDeploymentConfig + $tree = GetGithubTree + $remoteShaTable = GetCommitShaTable $tree + + $existingConfigSha = $global:localCsvTablefinal[$configPath] + $remoteConfigSha = $remoteShaTable[$configPath] + $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) + + if ($remoteConfigSha) { + $global:updatedCsvTable[$configPath] = $remoteConfigSha + } + + $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") + Deployment $fullDeploymentFlag $remoteShaTable $tree +} + +main \ No newline at end of file From 042749c9ee755a07e7304e820beee0b92202fa36 Mon Sep 17 00:00:00 2001 From: "azure-sentinel[bot]" <81647488+azure-sentinel[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:08:50 +0000 Subject: [PATCH 43/45] Workflow file for Sentinel-Deploy --- ...y-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml diff --git a/.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml b/.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml new file mode 100644 index 000000000..37d20bb52 --- /dev/null +++ b/.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml @@ -0,0 +1,94 @@ +name: Deploy Content to tomer-mx-test [dcc431fe-4d54-4638-b0f6-c5c0c1ed39be] +# Note: This workflow will deploy everything in the root directory. +# To deploy content only from a specific path (for example SentinelContent): +# 1. Add the target path to the "paths" property like such +# paths: +# - 'SentinelContent/**' +# - '!.github/workflows/**' +# - '.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml' +# 2. Append the path to the directory environment variable below +# directory: '${{ github.workspace }}/SentinelContent' + +on: + push: + branches: [ patch-1 ] + paths: + - '**' + - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow + - '.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml' + +jobs: + deploy-content: + runs-on: windows-latest + env: + resourceGroupName: 'tomer-mx-test-rg' + workspaceName: 'tomer-mx-test' + workspaceId: '157f17cb-82d6-4031-94c2-adc63d9a3cdd' + directory: '${{ github.workspace }}' + cloudEnv: 'AzureCloud' + contentTypes: 'CustomDetection' + branch: 'patch-1' + sourceControlId: 'dcc431fe-4d54-4638-b0f6-c5c0c1ed39be' + rootDirectory: '${{ github.workspace }}' + githubAuthToken: ${{ secrets.GITHUB_TOKEN }} + smartDeployment: 'true' + permissions: + contents: write + id-token: write # Require write permission to Fetch an OIDC token. + + steps: + - name: Login to Azure (Attempt 1) + continue-on-error: true + id: login1 + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_dcc431fe4d544638b0f6c5c0c1ed39be }} + tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_dcc431fe4d544638b0f6c5c0c1ed39be }} + subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_dcc431fe4d544638b0f6c5c0c1ed39be }} + environment: 'AzureCloud' + audience: api://AzureADTokenExchange + enable-AzPSSession: true + + - name: Wait 30 seconds if login attempt 1 failed + if: ${{ steps.login1.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 2) + continue-on-error: true + id: login2 + uses: azure/login@v2 + if: ${{ steps.login1.outcome=='failure' }} + with: + client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_dcc431fe4d544638b0f6c5c0c1ed39be }} + tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_dcc431fe4d544638b0f6c5c0c1ed39be }} + subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_dcc431fe4d544638b0f6c5c0c1ed39be }} + environment: 'AzureCloud' + audience: api://AzureADTokenExchange + enable-AzPSSession: true + + - name: Wait 30 seconds if login attempt 2 failed + if: ${{ steps.login2.outcome=='failure' }} + run: powershell Start-Sleep -s 30 + + - name: Login to Azure (Attempt 3) + continue-on-error: false + id: login3 + uses: azure/login@v2 + if: ${{ steps.login2.outcome=='failure' }} + with: + client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_dcc431fe4d544638b0f6c5c0c1ed39be }} + tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_dcc431fe4d544638b0f6c5c0c1ed39be }} + subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_dcc431fe4d544638b0f6c5c0c1ed39be }} + environment: 'AzureCloud' + audience: api://AzureADTokenExchange + enable-AzPSSession: true + + - name: Checkout + uses: actions/checkout@v3 + + - name: Deploy Content to Microsoft Sentinel + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 \ No newline at end of file From 5c567d6f8f9cce598fdece1048389a46dbeb947a Mon Sep 17 00:00:00 2001 From: "azure-sentinel[bot]" <81647488+azure-sentinel[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:08:58 +0000 Subject: [PATCH 44/45] Remove deployment script file azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 --- ...y-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 | 650 ------------------ 1 file changed, 650 deletions(-) delete mode 100644 .github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 diff --git a/.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 b/.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 deleted file mode 100644 index a01e7a643..000000000 --- a/.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -## Globals ## -$CloudEnv = $Env:cloudEnv -$ResourceGroupName = $Env:resourceGroupName -$WorkspaceName = $Env:workspaceName -$WorkspaceId = $Env:workspaceId -$Directory = $Env:directory -$contentTypes = $Env:contentTypes -$contentTypeMapping = @{ - "AnalyticsRule"=@("Microsoft.OperationalInsights/workspaces/providers/alertRules", "Microsoft.OperationalInsights/workspaces/providers/alertRules/actions"); - "AutomationRule"=@("Microsoft.OperationalInsights/workspaces/providers/automationRules"); - "HuntingQuery"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Parser"=@("Microsoft.OperationalInsights/workspaces/savedSearches"); - "Playbook"=@("Microsoft.Web/connections", "Microsoft.Logic/workflows", "Microsoft.Web/customApis"); - "Workbook"=@("Microsoft.Insights/workbooks"); - "CustomDetection"=@("Microsoft.XDR/customDetections"); -} -$sourceControlId = $Env:sourceControlId -$rootDirectory = $Env:rootDirectory -$githubAuthToken = $Env:githubAuthToken -$githubRepository = $Env:GITHUB_REPOSITORY -$branchName = $Env:branch -$smartDeployment = $Env:smartDeployment -$newResourceBranch = $branchName + "-sentinel-deployment" -$csvPath = "$rootDirectory\.sentinel\tracking_table_$sourceControlId.csv" -$configPath = "$rootDirectory\sentinel-deployment.config" -$global:localCsvTablefinal = @{} -$global:updatedCsvTable = @{} -$global:parameterFileMapping = @{} -$global:prioritizedContentFiles = @() -$global:excludeContentFiles = @() - -$guidPattern = '(\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b)' -$namePattern = '([-\w\._\(\)]+)' -$sentinelResourcePatterns = @{ - "AnalyticsRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/alertRules/$namePattern" - "AutomationRule" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/providers/Microsoft.SecurityInsights/automationRules/$namePattern" - "HuntingQuery" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Parser" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.OperationalInsights/workspaces/$namePattern/savedSearches/$namePattern" - "Playbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Logic/workflows/$namePattern" - "Workbook" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.Insights/workbooks/$namePattern" - "CustomDetection" = "/subscriptions/$guidPattern/resourceGroups/$namePattern/providers/Microsoft.XDR/customDetections/$namePattern" -} - -if ([string]::IsNullOrEmpty($contentTypes)) { - $contentTypes = "AnalyticsRule" -} - -$metadataFilePath = "metadata.json" -@" -{ - "`$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "parentResourceId": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "sourceControlId": { - "type": "string" - }, - "workspace": { - "type": "string" - }, - "contentId": { - "type": "string" - }, - "customVersion": { - "type": "string" - } - }, - "variables": { - "metadataName": "[concat(toLower(parameters('kind')), '-', parameters('contentId'))]" - }, - "resources": [ - { - "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", - "apiVersion": "2022-01-01-preview", - "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',variables('metadataName'))]", - "properties": { - "parentId": "[parameters('parentResourceId')]", - "kind": "[parameters('kind')]", - "customVersion": "[parameters('customVersion')]", - "source": { - "kind": "SourceRepository", - "name": "Repositories", - "sourceId": "[parameters('sourceControlId')]" - } - } - } - ] -} -"@ | Out-File -FilePath $metadataFilePath - -$resourceTypes = $contentTypes.Split(",") | ForEach-Object { $contentTypeMapping[$_] } | ForEach-Object { $_.ToLower() } -$MaxRetries = 3 -$secondsBetweenAttempts = 5 - -#Converts hashtable to string that can be set as content when pushing csv file -function ConvertTableToString { - $output = "FileName, CommitSha`n" - $global:updatedCsvTable.GetEnumerator() | ForEach-Object { - $key = RelativePathWithBackslash $_.Key - $output += "{0},{1}`n" -f $key, $_.Value - } - return $output -} - -$header = @{ - "authorization" = "Bearer $githubAuthToken" -} - -#Gets all files and commit shas using Get Trees API -function GetGithubTree { - $branchResponse = AttemptInvokeRestMethod "Get" "https://api.github.com/repos/$githubRepository/branches/$branchName" $null $null 3 - $treeUrl = "https://api.github.com/repos/$githubRepository/git/trees/" + $branchResponse.commit.sha + "?recursive=true" - $getTreeResponse = AttemptInvokeRestMethod "Get" $treeUrl $null $null 3 - return $getTreeResponse -} - -#Creates a table using the reponse from the tree api, creates a table -function GetCommitShaTable($getTreeResponse) { - $shaTable = @{} - $supportedExtensions = @(".json", ".bicep", ".bicepparam"); - $getTreeResponse.tree | ForEach-Object { - $truePath = AbsolutePathWithSlash $_.path - if ((([System.IO.Path]::GetExtension($_.path) -in $supportedExtensions)) -or ($truePath -eq $configPath)) - { - $shaTable.Add($truePath, $_.sha) - } - } - return $shaTable -} - -function PushCsvToRepo() { - $content = ConvertTableToString - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 0) { - git switch --orphan $newResourceBranch - git commit --allow-empty -m "Initial commit on orphan branch" - git push -u origin $newResourceBranch - New-Item -ItemType "directory" -Path ".sentinel" - } else { - git fetch > $null - git checkout $newResourceBranch - } - - Write-Output $content > $relativeCsvPath - git add $relativeCsvPath - git commit -m "Modified tracking table" - git push -u origin $newResourceBranch - git checkout $branchName -} - -function ReadCsvToTable { - $csvTable = Import-Csv -Path $csvPath - $HashTable=@{} - foreach($r in $csvTable) - { - $key = AbsolutePathWithSlash $r.FileName - $HashTable[$key]=$r.CommitSha - } - return $HashTable -} - -function AttemptInvokeRestMethod($method, $url, $body, $contentTypes, $maxRetries) { - $Stoploop = $false - $retryCount = 0 - do { - try { - $result = Invoke-RestMethod -Uri $url -Method $method -Headers $header -Body $body -ContentType $contentTypes - $Stoploop = $true - } - catch { - if ($retryCount -gt $maxRetries) { - Write-Host "[Error] API call failed after $retryCount retries: $_" - $Stoploop = $true - } - else { - Write-Host "[Warning] API call failed: $_.`n Conducting retry #$retryCount." - Start-Sleep -Seconds 5 - $retryCount = $retryCount + 1 - } - } - } - While ($Stoploop -eq $false) - return $result -} - -function AttemptDeployMetadata($deploymentName, $resourceGroupName, $templateObject, $templateType, $paramFileType, $containsWorkspaceParam) { - $deploymentInfo = $null - try { - $deploymentInfo = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Ignore - } - catch { - Write-Host "[Warning] Unable to fetch deployment info for $deploymentName, no metadata was created for the resources in the file. Error: $_" - return - } - $deploymentInfo | Where-Object { $_.TargetResource -ne "" } | ForEach-Object { - $resource = $_.TargetResource - $sentinelContentKinds = GetContentKinds $resource - if ($sentinelContentKinds.Count -gt 0) { - $contentKind = ToContentKind $sentinelContentKinds $resource $templateObject - - if ($contentKind -eq "CustomDetection") { - Write-Host "[Info] Skipping metadata deployment for CustomDetection content." - return - } - - $contentId = $resource.Split("/")[-1] - $metadataCustomVersion = GetMetadataCustomVersion $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $false - $currentAttempt = 0 - - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - New-AzResourceGroupDeployment -Name "md-$deploymentName" -ResourceGroupName $ResourceGroupName -TemplateFile $metadataFilePath ` - -parentResourceId $resource ` - -kind $contentKind ` - -contentId $contentId ` - -sourceControlId $sourceControlId ` - -workspace $workspaceName ` - -customVersion $metadataCustomVersion ` - -ErrorAction Stop | Out-Host - Write-Host "[Info] Created metadata for $contentKind with parent resource id $resource" - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable "md-$deploymentName")) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with parent resource id $resource with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy metadata for $contentKind after $currentAttempt attempts with error: $err" - } - } - } - } - } - } -} - -function GetMetadataCustomVersion($templateType, $paramFileType, $containsWorkspaceParam){ - $customVersion = $templateType + "-" + $paramFileType - if($containsWorkspaceParam){ - $customVersion += "-WorkspaceParam" - } - if($smartDeployment -eq "true"){ - $customVersion += "-SmartTracking" - } - return $customVersion -} - -function GetContentKinds($resource) { - return $sentinelResourcePatterns.Keys | Where-Object { $resource -match $sentinelResourcePatterns[$_] } -} - -function ToContentKind($contentKinds, $resource, $templateObject) { - if ($contentKinds.Count -eq 1) { - return $contentKinds - } - if ($null -ne $resource -and $resource.Contains('savedSearches')) { - if ($templateObject.resources.properties.Category -eq "Hunting Queries") { - return "HuntingQuery" - } - return "Parser" - } - return $null -} - -function IsValidTemplate($path, $templateObject, $parameterFile) { - Try { - if (DoesContainWorkspaceParam $templateObject) { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -workspace $WorkspaceName - } - else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $WorkspaceName - } - } - else { - if ($parameterFile) { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile - } else { - Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $path - } - } - - return $true - } - Catch { - Write-Host "[Warning] The file $path is not valid: $_" - return $false - } -} - -function IsRetryable($deploymentName) { - $retryableStatusCodes = "Conflict","TooManyRequests","InternalServerError","DeploymentActive" - Try { - $deploymentResult = Get-AzResourceGroupDeploymentOperation -DeploymentName $deploymentName -ResourceGroupName $ResourceGroupName -ErrorAction Stop - return $retryableStatusCodes -contains $deploymentResult.StatusCode - } - Catch { - return $false - } -} - -function IsValidResourceType($template) { - try { - $isAllowedResources = $true - $template.resources | ForEach-Object { - $isAllowedResources = $resourceTypes.contains($_.type.ToLower()) -and $isAllowedResources - } - } - catch { - Write-Host "[Error] Failed to check valid resource type." - $isAllowedResources = $false - } - return $isAllowedResources -} - -function DoesContainWorkspaceParam($templateObject) { - $templateObject.parameters.PSobject.Properties.Name -contains "workspace" -} - -function AttemptDeployment($path, $parameterFile, $deploymentName, $templateObject, $templateType) { - Write-Host "[Info] Deploying $path with deployment name $deploymentName" - - $isValid = IsValidTemplate $path $templateObject $parameterFile - if (-not $isValid) { - Write-Host "[Error] Not deploying $path since the template is not valid" - return $false - } - $isSuccess = $false - $currentAttempt = 0 - While (($currentAttempt -lt $MaxRetries) -and (-not $isSuccess)) - { - $currentAttempt ++ - Try - { - Write-Host "[Info] Deploy $path with parameter file: [$parameterFile]" - $paramFileType = if(!$parameterFile) {"NoParam"} elseif($parameterFile -like "*.bicepparam") {"BicepParam"} else {"JsonParam"} - $containsWorkspaceParam = DoesContainWorkspaceParam $templateObject - if ($containsWorkspaceParam) - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -workspace $workspaceName -ErrorAction Stop | Out-Host - } - } - else - { - if ($parameterFile) { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -TemplateParameterFile $parameterFile -ErrorAction Stop | Out-Host - } - else - { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $ResourceGroupName -TemplateFile $path -ErrorAction Stop | Out-Host - } - } - AttemptDeployMetadata $deploymentName $ResourceGroupName $templateObject $templateType $paramFileType $containsWorkspaceParam - - $isSuccess = $true - } - Catch [Exception] - { - $err = $_ - if (-not (IsRetryable $deploymentName)) - { - Write-Host "[Warning] Failed to deploy $path with error: $err" - break - } - else - { - if ($currentAttempt -le $MaxRetries) - { - Write-Host "[Warning] Failed to deploy $path with error: $err. Retrying in $secondsBetweenAttempts seconds..." - Start-Sleep -Seconds $secondsBetweenAttempts - } - else - { - Write-Host "[Warning] Failed to deploy $path after $currentAttempt attempts with error: $err" - } - } - } - } - return $isSuccess -} - -function GenerateDeploymentName() { - $randomId = [guid]::NewGuid() - return "Sentinel_Deployment_$randomId" -} - -#Load deployment configuration -function LoadDeploymentConfig() { - Write-Host "[Info] load the deployment configuration from [$configPath]" - $global:parameterFileMapping = @{} - $global:prioritizedContentFiles = @() - $global:excludeContentFiles = @() - try { - if (Test-Path $configPath) { - $deployment_config = Get-Content $configPath | Out-String | ConvertFrom-Json - $parameterFileMappings = @{} - if ($deployment_config.parameterfilemappings) { - $deployment_config.parameterfilemappings.psobject.properties | ForEach { $parameterFileMappings[$_.Name] = $_.Value } - } - $key = ($parameterFileMappings.Keys | ? { $_ -eq $workspaceId }) - if ($null -ne $key) { - $parameterFileMappings[$key].psobject.properties | ForEach { $global:parameterFileMapping[$_.Name] = $_.Value } - } - if ($deployment_config.prioritizedcontentfiles) { - $global:prioritizedContentFiles = $deployment_config.prioritizedcontentfiles - } - $excludeList = $global:parameterFileMapping.Values + $global:prioritizedcontentfiles - if ($deployment_config.excludecontentfiles) { - $excludeList = $excludeList + $deployment_config.excludecontentfiles - } - $global:excludeContentFiles = $excludeList | Where-Object { Test-Path (AbsolutePathWithSlash $_) } - } - } - catch { - Write-Host "[Warning] An error occurred while trying to load deployment configuration." - Write-Host "Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function filterContentFile($fullPath) { - $temp = RelativePathWithBackslash $fullPath - return $global:excludeContentFiles | Where-Object {$temp.StartsWith($_, 'CurrentCultureIgnoreCase')} -} - -function RelativePathWithBackslash($absolutePath) { - return $absolutePath.Replace($rootDirectory + "\", "").Replace("\", "/") -} - -function AbsolutePathWithSlash($relativePath) { - return Join-Path -Path $rootDirectory -ChildPath $relativePath -} - -#resolve parameter file name, return $null if there is none. -function GetParameterFile($path) { - if ($path.Length -eq 0) { - return $null - } - - $index = RelativePathWithBackslash $path - $key = ($global:parameterFileMapping.Keys | Where-Object { $_ -eq $index }) - if ($key) { - $mappedParameterFile = AbsolutePathWithSlash $global:parameterFileMapping[$key] - if (Test-Path $mappedParameterFile) { - return $mappedParameterFile - } - } - - $extension = [System.IO.Path]::GetExtension($path) - if ($extension -ne ".json" -and $extension -ne ".bicep") { - return $null - } - - $parameterFilePrefix = $path.Substring(0, $path.Length - $extension.Length) - - # Check for workspace-specific parameter file - if ($extension -eq ".bicep") { - $workspaceParameterFile = $parameterFilePrefix + "-$WorkspaceId.bicepparam" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - } - - $workspaceParameterFile = $parameterFilePrefix + ".parameters-$WorkspaceId.json" - if (Test-Path $workspaceParameterFile) { - return $workspaceParameterFile - } - - # Check for parameter file - if ($extension -eq ".bicep") { - $defaultParameterFile = $parameterFilePrefix + ".bicepparam" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - } - - $defaultParameterFile = $parameterFilePrefix + ".parameters.json" - Write-Host "Default parameter file: $defaultParameterFile" - if (Test-Path $defaultParameterFile) { - return $defaultParameterFile - } - - return $null -} - -function Deployment($fullDeploymentFlag, $remoteShaTable, $tree) { - Write-Host "Starting Deployment for Files in path: $Directory" - if (Test-Path -Path $Directory) - { - $totalFiles = 0; - $totalFailed = 0; - $iterationList = @() - $global:prioritizedContentFiles | ForEach-Object { $iterationList += (AbsolutePathWithSlash $_) } - Get-ChildItem -Path $Directory -Recurse -Include *.bicep, *.json -exclude *metadata.json, *.parameters*.json, *.bicepparam, bicepconfig.json | - Where-Object { $null -eq ( filterContentFile $_.FullName ) } | - Select-Object -Property FullName | - ForEach-Object { $iterationList += $_.FullName } - $iterationList | ForEach-Object { - $path = $_ - Write-Host "[Info] Try to deploy $path" - if (-not (Test-Path $path)) { - Write-Host "[Warning] Skipping deployment for $path. The file doesn't exist." - return - } - - if ($path -like "*.bicep") { - $templateType = "Bicep" - $templateObject = bicep build $path --stdout | Out-String | ConvertFrom-Json - } else { - $templateType = "ARM" - $templateObject = Get-Content $path | Out-String | ConvertFrom-Json - } - - if (-not (IsValidResourceType $templateObject)) - { - Write-Host "[Warning] Skipping deployment for $path. The file contains resources for content that was not selected for deployment. Please add content type to connection if you want this file to be deployed." - return - } - $parameterFile = GetParameterFile $path - $result = SmartDeployment $fullDeploymentFlag $remoteShaTable $path $parameterFile $templateObject $templateType - if ($result.isSuccess -eq $false) { - $totalFailed++ - } - if (-not $result.skip) { - $totalFiles++ - } - if ($result.isSuccess -or $result.skip) { - $global:updatedCsvTable[$path] = $remoteShaTable[$path] - if ($parameterFile) { - $global:updatedCsvTable[$parameterFile] = $remoteShaTable[$parameterFile] - } - } - } - PushCsvToRepo - if ($totalFiles -gt 0 -and $totalFailed -gt 0) - { - $err = "$totalFailed of $totalFiles deployments failed." - Throw $err - } - } - else - { - Write-Output "[Warning] $Directory not found. nothing to deploy" - } -} - -function SmartDeployment($fullDeploymentFlag, $remoteShaTable, $path, $parameterFile, $templateObject, $templateType) { - try { - $skip = $false - $isSuccess = $null - if (!$fullDeploymentFlag) { - $existingSha = $global:localCsvTablefinal[$path] - $remoteSha = $remoteShaTable[$path] - $skip = (($existingSha) -and ($existingSha -eq $remoteSha)) - if ($skip -and $parameterFile) { - $existingShaForParameterFile = $global:localCsvTablefinal[$parameterFile] - $remoteShaForParameterFile = $remoteShaTable[$parameterFile] - $skip = (($existingShaForParameterFile) -and ($existingShaForParameterFile -eq $remoteShaForParameterFile)) - } - } - if (!$skip) { - $deploymentName = GenerateDeploymentName - $isSuccess = AttemptDeployment $path $parameterFile $deploymentName $templateObject $templateType - } - return @{ - skip = $skip - isSuccess = $isSuccess - } - } - catch { - Write-Host "[Error] An error occurred while trying to deploy file $path. Exception details: $_" - Write-Host $_.ScriptStackTrace - } -} - -function TryGetCsvFile { - if (Test-Path $csvPath) { - $global:localCsvTablefinal = ReadCsvToTable - Remove-Item -Path $csvPath - git add $csvPath - git commit -m "Removed tracking file and moved to new sentinel created branch" - git push origin $branchName - } - - $relativeCsvPath = RelativePathWithBackslash $csvPath - $resourceBranchExists = git ls-remote --heads "https://github.com/$githubRepository" $newResourceBranch | wc -l - - if ($resourceBranchExists -eq 1) { - git fetch > $null - git checkout $newResourceBranch - - if (Test-Path $relativeCsvPath) { - $global:localCsvTablefinal = ReadCsvToTable - } - git checkout $branchName - } -} - -function main() { - git config --global user.email "donotreply@microsoft.com" - git config --global user.name "Sentinel" - - TryGetCsvFile - LoadDeploymentConfig - $tree = GetGithubTree - $remoteShaTable = GetCommitShaTable $tree - - $existingConfigSha = $global:localCsvTablefinal[$configPath] - $remoteConfigSha = $remoteShaTable[$configPath] - $modifiedConfig = ($existingConfigSha -xor $remoteConfigSha) -or ($existingConfigSha -and $remoteConfigSha -and ($existingConfigSha -ne $remoteConfigSha)) - - if ($remoteConfigSha) { - $global:updatedCsvTable[$configPath] = $remoteConfigSha - } - - $fullDeploymentFlag = $modifiedConfig -or ($smartDeployment -eq "false") - Deployment $fullDeploymentFlag $remoteShaTable $tree -} - -main \ No newline at end of file From 4cff28a4872dacbb1c91ac9e2c7c1820d0536f48 Mon Sep 17 00:00:00 2001 From: "azure-sentinel[bot]" <81647488+azure-sentinel[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:08:59 +0000 Subject: [PATCH 45/45] Remove workflow sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml --- ...y-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml | 94 ------------------- 1 file changed, 94 deletions(-) delete mode 100644 .github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml diff --git a/.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml b/.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml deleted file mode 100644 index 37d20bb52..000000000 --- a/.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Deploy Content to tomer-mx-test [dcc431fe-4d54-4638-b0f6-c5c0c1ed39be] -# Note: This workflow will deploy everything in the root directory. -# To deploy content only from a specific path (for example SentinelContent): -# 1. Add the target path to the "paths" property like such -# paths: -# - 'SentinelContent/**' -# - '!.github/workflows/**' -# - '.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml' -# 2. Append the path to the directory environment variable below -# directory: '${{ github.workspace }}/SentinelContent' - -on: - push: - branches: [ patch-1 ] - paths: - - '**' - - '!.github/workflows/**' # this filter prevents other workflow changes from triggering this workflow - - '.github/workflows/sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.yml' - -jobs: - deploy-content: - runs-on: windows-latest - env: - resourceGroupName: 'tomer-mx-test-rg' - workspaceName: 'tomer-mx-test' - workspaceId: '157f17cb-82d6-4031-94c2-adc63d9a3cdd' - directory: '${{ github.workspace }}' - cloudEnv: 'AzureCloud' - contentTypes: 'CustomDetection' - branch: 'patch-1' - sourceControlId: 'dcc431fe-4d54-4638-b0f6-c5c0c1ed39be' - rootDirectory: '${{ github.workspace }}' - githubAuthToken: ${{ secrets.GITHUB_TOKEN }} - smartDeployment: 'true' - permissions: - contents: write - id-token: write # Require write permission to Fetch an OIDC token. - - steps: - - name: Login to Azure (Attempt 1) - continue-on-error: true - id: login1 - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_dcc431fe4d544638b0f6c5c0c1ed39be }} - tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_dcc431fe4d544638b0f6c5c0c1ed39be }} - subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_dcc431fe4d544638b0f6c5c0c1ed39be }} - environment: 'AzureCloud' - audience: api://AzureADTokenExchange - enable-AzPSSession: true - - - name: Wait 30 seconds if login attempt 1 failed - if: ${{ steps.login1.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 2) - continue-on-error: true - id: login2 - uses: azure/login@v2 - if: ${{ steps.login1.outcome=='failure' }} - with: - client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_dcc431fe4d544638b0f6c5c0c1ed39be }} - tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_dcc431fe4d544638b0f6c5c0c1ed39be }} - subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_dcc431fe4d544638b0f6c5c0c1ed39be }} - environment: 'AzureCloud' - audience: api://AzureADTokenExchange - enable-AzPSSession: true - - - name: Wait 30 seconds if login attempt 2 failed - if: ${{ steps.login2.outcome=='failure' }} - run: powershell Start-Sleep -s 30 - - - name: Login to Azure (Attempt 3) - continue-on-error: false - id: login3 - uses: azure/login@v2 - if: ${{ steps.login2.outcome=='failure' }} - with: - client-id: ${{ secrets.AZURE_SENTINEL_CLIENTID_dcc431fe4d544638b0f6c5c0c1ed39be }} - tenant-id: ${{ secrets.AZURE_SENTINEL_TENANTID_dcc431fe4d544638b0f6c5c0c1ed39be }} - subscription-id: ${{ secrets.AZURE_SENTINEL_SUBSCRIPTIONID_dcc431fe4d544638b0f6c5c0c1ed39be }} - environment: 'AzureCloud' - audience: api://AzureADTokenExchange - enable-AzPSSession: true - - - name: Checkout - uses: actions/checkout@v3 - - - name: Deploy Content to Microsoft Sentinel - uses: azure/powershell@v2 - with: - azPSVersion: 'latest' - inlineScript: | - ${{ github.workspace }}//.github/workflows/azure-sentinel-deploy-dcc431fe-4d54-4638-b0f6-c5c0c1ed39be.ps1 \ No newline at end of file