Azure Lesson 70 of 137

Sentinel Detection-as-Code: Content Hub, Repositories, and CI/CD Pipelines

A SOC that clicks “Create analytics rule” in the portal is building a detection estate it cannot reason about. Six months in, nobody can tell you which rules changed, who tuned the threshold on the impossible-travel detection, or whether the rule running in production matches the one in your DR workspace. There is no diff, no peer review, no rollback. When an analyst “fixes” a noisy rule at 2 a.m. by widening a filter, that change is invisible until it misses the next real intrusion.

Detection-as-code fixes this the same way infrastructure-as-code fixed configuration drift: the Git repository becomes the source of truth, every change is a reviewed pull request, and a pipeline deploys identical content to every workspace. This guide builds that system on Microsoft Sentinel - repository structure, KQL validation, a CI/CD pipeline, and multi-workspace and MSSP fan-out - and is opinionated about where Microsoft’s built-in repositories connector ends and where you need your own pipeline.

In a nutshell

Think about how a modern software team ships a change. Nobody logs into the live production server and edits the running app by hand — that would be untraceable and terrifying. Instead they write the change as code in a branch, a teammate reviews the pull request, automated tests run in a pipeline, and only then does a robot — the pipeline — deploy it. Detection-as-code applies that exact discipline to your security detections.

A “detection” here is an analytics rule: a saved query that watches your logs for an attack pattern and raises an alert. In the seductive-but-dangerous portal way, you click “Create analytics rule,” fill in a form, and hope you remember what you did. In the detection-as-code way, that rule is a file in a Git repository — reviewed, versioned, tested, and deployed by pipeline to every workspace identically. The portal becomes a place you look, not a place you change things.

Here is the whole cast in plain language. Git is the single source of truth — the folder of rule files everyone agrees is “the real config.” A pull request is the peer review: a colleague reads the diff of your KQL before it goes live. CI (continuous integration) is the robot that checks your work — at minimum, “does this query even parse?” A pipeline is the robot that deploys the approved rules to your Sentinel workspace(s). And the Content Hub is Microsoft’s app-store of ready-made detections you start from, instead of writing everything yourself.

Why should a beginner care? Because the portal has no memory. It cannot tell you who changed a rule, when, or why; it has no undo and no diff; and it happily lets two workspaces drift apart. Detection-as-code trades a little setup for the ability to answer “prove this detection ran, unmodified, all quarter” with a git log, and to rebuild an entire SOC’s content from a git clone. That is the difference between a detection estate you can reason about and one you can only apologise for.

Level: Advanced · Time: ~40 min

Prerequisites

After this lesson you will be able to

Sentinel detection-as-code: Git → CI validation → deploy analytics rules → Content Hub

The pipeline reads left to right: detections live in Git as Bicep + KQL, a CI stage type-checks and bind-validates them (the test gate the built-in connector lacks), an OIDC-authenticated deploy fans them out to every workspace, and Content Hub solutions seed the repo as a pinned, forked baseline.

1. Why detection-as-code, concretely

Three failure modes drive the decision, and naming them keeps the design honest:

Problem Portal-managed reality Detection-as-code
Drift Dev, staging, prod rules diverge silently One repo deploys identical content everywhere
No peer review A tuning change ships with zero eyes on it Every KQL change is a reviewed PR with a diff
Not reproducible Rebuilding a workspace means re-clicking git clone + pipeline rebuilds the SOC content

The unit of work shifts from “a rule in a workspace” to “a content file in a branch.” That reframing is the whole point. A detection is now testable, reviewable, and versioned, and the workspace is a deployment target, not a database you edit by hand.

Detection-as-code does not replace the analyst. It replaces the undocumented, unreviewed mutation. The analyst still writes the KQL - they just do it in a branch, with a colleague reviewing the logic before it touches production.

2. Structure the content repository

Lay the repo out so a human and a pipeline can both navigate it. Separate content by type (because deployment tooling and validation differ per type) and keep environment-specific values in parameter files, never in the rule body.

sentinel-content/
  analytics-rules/
    impossible-travel.bicep
    impossible-travel.parameters.json          # default params
    impossible-travel.parameters-<prodWsId>.json
    aadsts-brute-force.bicep
  hunting-queries/
    rare-process-by-host.json
  workbooks/
    identity-overview.json
  parsers/
    asim-auth-custom.kql
  shared/
    rule.bicep                                  # one reusable module
  tests/
    kql-validate.ps1
    schema-validate.ps1
  sentinel-deployment.config                    # repositories-connector config
  .github/workflows/deploy.yml

Author rules as Bicep rather than hand-written ARM JSON. Bicep gives you parameters, type checking, and loadTextContent() so the KQL lives in a separate .kql file that linters and your editor understand - instead of being trapped as an escaped one-line string inside JSON.

A single reusable module keeps every rule consistent. This is the schema Sentinel actually expects for a scheduled rule:

// shared/rule.bicep
@description('Log Analytics / Sentinel workspace name')
param workspaceName string

@description('Stable GUID for this rule - keep it constant across edits')
param ruleId string

param displayName string
param description string = ''
param query string

@allowed(['High', 'Medium', 'Low', 'Informational'])
param severity string

@description('ISO 8601 duration, e.g. PT1H')
param queryFrequency string = 'PT1H'

@description('ISO 8601 duration, e.g. PT1H')
param queryPeriod string = 'PT1H'

@allowed(['GreaterThan', 'LessThan', 'Equal', 'NotEqual'])
param triggerOperator string = 'GreaterThan'

param triggerThreshold int = 0
param enabled bool = true
param tactics array = []
param techniques array = []

resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
  name: workspaceName
}

resource rule 'Microsoft.SecurityInsights/alertRules@2025-09-01' = {
  name: ruleId
  scope: workspace
  kind: 'Scheduled'
  properties: {
    displayName: displayName
    description: description
    severity: severity
    enabled: enabled
    query: query
    queryFrequency: queryFrequency
    queryPeriod: queryPeriod
    triggerOperator: triggerOperator
    triggerThreshold: triggerThreshold
    suppressionDuration: 'PT1H'
    suppressionEnabled: false
    tactics: tactics
    techniques: techniques
    incidentConfiguration: {
      createIncident: true
      groupingConfiguration: {
        enabled: true
        reopenClosedIncident: false
        lookbackDuration: 'PT5H'
        matchingMethod: 'AllEntities'
      }
    }
  }
}

The ruleId deserves emphasis. The resource name is the rule’s identity in Azure. Generate a GUID once, store it in the file, and never change it - then edits are idempotent updates, not delete-and-recreate (which loses incident history and resets the rule’s alert lineage). A deleted-and-recreated rule is a new rule as far as your incidents are concerned.

The per-rule file just supplies values and imports the module:

// analytics-rules/impossible-travel.bicep
param workspaceName string

module r '../shared/rule.bicep' = {
  name: 'impossible-travel'
  params: {
    workspaceName: workspaceName
    ruleId: 'b2f1c7a4-9d3e-4a8b-bb21-7e5d4c0a1f93'  // stable GUID
    displayName: 'Impossible travel - successful sign-in'
    description: 'Successful sign-ins from geographically distant locations within an implausible window.'
    severity: 'Medium'
    query: loadTextContent('./impossible-travel.kql')
    queryFrequency: 'PT1H'
    queryPeriod: 'PT6H'
    triggerThreshold: 0
    tactics: ['InitialAccess', 'CredentialAccess']
    techniques: ['T1078']
  }
}

3. Author analytics rules with KQL validation

The KQL lives on its own so it is readable, diffable, and lintable:

// analytics-rules/impossible-travel.kql
let lookback = 6h;
let threshold_kmh = 800.0;   // faster than a commercial flight => impossible
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType == 0
| extend lat = toreal(LocationDetails.geoCoordinates.latitude),
         lon = toreal(LocationDetails.geoCoordinates.longitude)
| where isnotnull(lat) and isnotnull(lon)
| order by UserPrincipalName asc, TimeGenerated asc
| serialize
| extend prevLat = prev(lat), prevLon = prev(lon),
         prevTime = prev(TimeGenerated), prevUser = prev(UserPrincipalName)
| where UserPrincipalName == prevUser
| extend distKm = geo_distance_2points(lon, lat, prevLon, prevLat) / 1000.0
| extend hours = datetime_diff('second', TimeGenerated, prevTime) / 3600.0
| where hours > 0
| extend speedKmh = distKm / hours
| where speedKmh > threshold_kmh
| project TimeGenerated, UserPrincipalName, distKm, speedKmh, IPAddress

There is no separate “KQL compiler” you can shell out to, so validation is layered. The cheapest, most reliable gate is to submit the query to the workspace as a query job and fail on a parse or semantic error - the Log Analytics query engine is the authoritative validator:

# tests/kql-validate.ps1 - fail the build if any KQL is syntactically invalid
param([string]$WorkspaceId, [string]$RulesPath = './analytics-rules')

Connect-AzAccount -Identity | Out-Null
$failed = @()

Get-ChildItem -Path $RulesPath -Filter '*.kql' -Recurse | ForEach-Object {
    $kql = Get-Content $_.FullName -Raw
    # Wrap so we validate syntax cheaply without scanning real data
    $probe = "$kql`n| take 0"
    try {
        Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $probe -ErrorAction Stop | Out-Null
        Write-Host "PASS  $($_.Name)"
    } catch {
        Write-Host "FAIL  $($_.Name) :: $($_.Exception.Message)"
        $failed += $_.Name
    }
}

if ($failed.Count -gt 0) { throw "KQL validation failed: $($failed -join ', ')" }

Appending | take 0 means the engine parses, binds column references, and resolves functions - catching the real bugs (a renamed column, a typo’d operator, a function that does not exist) - without scanning data or costing query volume. That is the unit test for a detection: does this query bind against the schema it claims to read?

Add a second, schema-level gate that lints the rule structure itself. bicep build already type-checks the Bicep, so wire policy assertions on top - reject any rule missing MITRE tactics, any severity of High without createIncident: true, any queryPeriod shorter than queryFrequency. These are organizational invariants, not Azure’s, so they live in your test script.

4. Repositories connector versus a custom pipeline

Sentinel ships a first-party repositories feature (Content management -> Repositories) that connects a GitHub or Azure DevOps repo and auto-generates a workflow. Know exactly what it does before you decide.

When you create a connection, Sentinel registers an Entra app called Azure Sentinel Content Deployment App (suffixed with the repository ID), grants it access to the workspace’s resource group, and drops a workflow into your repo. On every push it deploys changed content. Its smart deployments feature tracks a CSV in the .sentinel folder to avoid redeploying files that did not change since the last commit.

Dimension Repositories connector Custom pipeline
Setup Minutes, portal-driven You build it
Auth Auto-created service principal Your federated credential / OIDC
Testing gates None - it deploys on push Whatever you wire in
Multi-workspace One connection per workspace One pipeline, fan-out loop
Promotion (dev -> prod) Not built in Branch/environment gates
Approvals None Native (environments / approvals)

The honest read: the connector is a deployer, not a CI system. It has no test stage and no promotion gates - it pushes whatever is on the branch. Use it for a single workspace where “merge to main = live in prod” is acceptable. For anything with environments, approvals, or pre-deployment validation, build your own pipeline. You can also do both: let the connector own the final deploy step while your pipeline owns testing and promotion, configured through sentinel-deployment.config.

That config file (root of the repo, lowercase keys) controls prioritization, exclusion, and parameter-file mapping for the connector:

{
  "prioritizedcontentfiles": [
    "analytics-rules/impossible-travel.bicep"
  ],
  "excludecontentfiles": [
    "analytics-rules/experimental-draft.bicep",
    "tests"
  ],
  "parameterfilemappings": {
    "11111111-1111-1111-1111-111111111111": {
      "analytics-rules/impossible-travel.bicep": "analytics-rules/impossible-travel.parameters-prod.json"
    }
  }
}

The GUID key is the workspace ID, which is how one repo maps different parameter files to different target workspaces. Note the casing - prioritizedcontentfiles, not camelCase - and forward slashes only; backslashes break the deployment script.

5. Build the CI/CD pipeline

Here is a GitHub Actions pipeline that does what the connector will not: lint, validate KQL, then deploy with OIDC (no stored secret). It authenticates via a federated credential, so there is no service-principal secret to rotate or leak.

# .github/workflows/deploy.yml
name: Sentinel Detection-as-Code

on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]

permissions:
  id-token: write      # required for OIDC federated login
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Bicep build (type + schema check)
        run: |
          for f in analytics-rules/*.bicep; do
            echo "Building $f"
            az bicep build --file "$f"
          done

      - name: KQL syntax validation
        uses: azure/powershell@v2
        with:
          inlineScript: ./tests/kql-validate.ps1 -WorkspaceId '${{ vars.DEV_WORKSPACE_ID }}'
          azPSVersion: latest

  deploy:
    needs: validate
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production        # gate with required reviewers
    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy analytics rules
        run: |
          for f in analytics-rules/*.bicep; do
            az deployment group create \
              --resource-group rg-sec-sentinel-prod \
              --template-file "$f" \
              --parameters workspaceName='law-sentinel-prod' \
              --name "sentinel-$(basename "$f" .bicep)-${{ github.run_id }}"
          done

Two design choices matter. First, validation runs on pull_request so a reviewer sees a green check (or a real failure) before merge - the test gate the connector lacks. Second, environment: production ties the deploy job to GitHub’s environment protection, so a human approves before anything reaches prod, and the approval is logged.

The federated credential is configured once on the app registration, scoped to this repo and (critically) to the production environment so a fork or an arbitrary branch cannot mint a token:

az ad app federated-credential create \
  --id "$APP_OBJECT_ID" \
  --parameters '{
    "name": "github-sentinel-prod",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:contoso/sentinel-content:environment:production",
    "audiences": ["api://AzureADTokenExchange"]
  }'

6. Multi-workspace and Lighthouse MSSP fan-out

One repo, many workspaces. Drive the fan-out from a manifest rather than hard-coding targets, so onboarding a workspace is a one-line edit:

// workspaces.json
[
  { "rg": "rg-sec-sentinel-prod", "ws": "law-sentinel-prod", "subId": "1111-...-1111" },
  { "rg": "rg-sec-sentinel-eu",   "ws": "law-sentinel-eu",   "subId": "2222-...-2222" }
]
# deploy every rule to every workspace in the manifest
jq -c '.[]' workspaces.json | while read -r ws; do
  rg=$(echo "$ws" | jq -r '.rg')
  name=$(echo "$ws" | jq -r '.ws')
  subId=$(echo "$ws" | jq -r '.subId')
  az account set --subscription "$subId"
  for f in analytics-rules/*.bicep; do
    az deployment group create \
      --resource-group "$rg" --template-file "$f" \
      --parameters workspaceName="$name" \
      --name "sentinel-$(basename "$f" .bicep)-$(date +%s)"
  done
done

For an MSSP, customer workspaces live in customer tenants. Do not store customer logs in your tenant - use Azure Lighthouse delegated access. Once each customer delegates the right scope to your SOC’s Entra group, your deployment principal can target their workspace cross-tenant with no per-tenant credential. The delegation is granted via an ARM offer deployed in the customer subscription:

// lighthouse-offer.bicep - deployed in the CUSTOMER subscription (subscription scope)
targetScope = 'subscription'

param mspTenantId string
param sentinelDeployGroupId string   // your SOC's Entra group object ID

resource assignment 'Microsoft.ManagedServices/registrationAssignments@2022-10-01' = {
  name: guid(subscription().id, sentinelDeployGroupId)
  properties: {
    registrationDefinitionId: definition.id
  }
}

resource definition 'Microsoft.ManagedServices/registrationDefinitions@2022-10-01' = {
  name: guid(subscription().id, 'sentinel-mssp')
  properties: {
    registrationDefinitionName: 'Sentinel content deployment'
    managedByTenantId: mspTenantId
    authorizations: [
      {
        principalId: sentinelDeployGroupId
        // Microsoft Sentinel Contributor
        roleDefinitionId: 'ab8e14d6-4a74-4a29-9ba8-549422addade'
      }
    ]
  }
}

Grant the least privilege that still works: Microsoft Sentinel Contributor lets the principal manage analytics rules without broad subscription rights. After delegation, the same fan-out loop deploys to delegated workspaces - the customer’s subscription ID goes in the manifest and Lighthouse handles the cross-tenant authorization transparently. No customer secret ever lands in your pipeline.

7. Content Hub solutions and safe upgrades

Most production Sentinel content does not start as your custom code - it starts as a Content Hub solution (Microsoft 365 Defender, Threat Intelligence, a vendor connector). These are packaged, versioned ARM templates installed from the gallery, and they cut across detection-as-code in a way that bites teams who ignore it.

The trap: you install a solution, then tune one of its rules in the portal. Later the solution ships v2.1, you upgrade, and your tuning is silently overwritten - the upgrade reapplies the template’s version of that rule. Treat Content Hub solutions as an upstream dependency, exactly like a third-party library:

// solutions.json - pin your upstream content versions
{
  "Microsoft Entra ID": "3.0.7",
  "Threat Intelligence": "3.0.3",
  "Microsoft Defender XDR": "3.1.2"
}

This is the same discipline as pinning a package lockfile. The Content Hub gives you breadth fast; the repo gives you control. Forking the rules you tune is what reconciles the two - your customizations survive every upstream upgrade because they are yours, with identities the solution does not own.

8. Promotion, rollback, and change auditing

Wire a real promotion path. Map environments to branches and let merges be the promotion event:

feature/*  --PR-->  develop  --(auto-deploy)-->  DEV workspace
develop    --PR-->  main     --(approval)----->  PROD workspace(s)

A merge to develop deploys to the dev workspace automatically; a tuner sees the rule fire on real-but-non-prod telemetry. A reviewed PR into main, gated by the production environment’s required reviewers, promotes it. Tag every production release so each deploy maps to an immutable commit:

git tag -a "release-2026.06.08" -m "Add impossible-travel rule; tune brute-force threshold"
git push origin "release-2026.06.08"

Rollback is git revert plus a re-run of the pipeline. Because rules carry stable GUIDs and Bicep deployment is idempotent, reverting the commit and redeploying restores the previous rule definition exactly - same identity, same incident lineage. There is no “undo” button in the portal that does this; the repo is the undo.

Auditing comes from three layers that together answer “who changed this detection, when, and why”:

Question Source of truth
Who changed the KQL and why? Git history + PR review thread
When did it deploy to prod? Pipeline run + git tag
What did Azure actually apply? AzureActivity deployment events / resource deployment history

Enterprise scenario

A global financial-services platform team ran Sentinel for the group SOC plus a managed offering for fourteen subsidiary banks, each in its own tenant for regulatory isolation. They had ~180 analytics rules and were managing them by hand. The breaking point came during an audit: a regulator asked them to prove that a specific anti-fraud detection had been running, unmodified, in a particular subsidiary’s workspace for the prior quarter. They could not. There was no diff, no deploy record, no way to show the rule in tenant 9 matched the approved baseline. Worse, a spot check found three subsidiaries running subtly different versions of that rule because someone had “quickly tuned” each one in the portal months earlier.

The constraint was hard: they could not centralize logs (data-residency law per subsidiary), and they could not put a credential for each customer tenant in a pipeline (the security team vetoed standing cross-tenant secrets outright).

They solved it with detection-as-code over Lighthouse. All 180 rules moved into one repo as Bicep modules with stable GUIDs. Each subsidiary delegated Microsoft Sentinel Contributor on its workspace resource group to a single SOC Entra group via a Lighthouse offer - so zero customer secrets entered the pipeline. The pipeline authenticated to the MSP tenant with OIDC, then fanned out across all fifteen workspaces (group + fourteen subsidiaries) from a manifest, Lighthouse handling each cross-tenant hop. The audit answer became trivial: git log for the rule, the tagged release, and the AzureActivity deployment event in that tenant’s workspace, all reconciling to one approved commit.

The detail that made the regulator happy was a drift-detection job. A scheduled run redeployed the repo in --what-if mode against every workspace and alerted on any difference - catching the moment any rule diverged from the baseline:

# nightly drift check - flags any rule that no longer matches the repo
az deployment group what-if \
  --resource-group "$rg" \
  --template-file analytics-rules/anti-fraud-velocity.bicep \
  --parameters workspaceName="$ws" \
  --no-pretty-print | tee whatif.json

# any change type other than NoChange/Ignore means the live rule drifted
if jq -e '.changes[] | select(.changeType != "NoChange" and .changeType != "Ignore")' whatif.json >/dev/null; then
  echo "DRIFT DETECTED in $ws" && exit 1
fi

Within a quarter, “prove this detection ran unmodified” went from a multi-day forensic exercise to a one-line query, and portal-side tuning effectively stopped because the pipeline made it pointless - any manual change was reverted by the next deploy and flagged by drift detection.

Verify

Confirm the system end to end, not just that the pipeline went green:

  1. Rule exists and matches the repo. In the target workspace, open the analytics rule and confirm its query matches the committed .kql byte for byte.

    az sentinel alert-rule show \
      --resource-group rg-sec-sentinel-prod \
      --workspace-name law-sentinel-prod \
      --rule-id b2f1c7a4-9d3e-4a8b-bb21-7e5d4c0a1f93 \
      --query "{name:displayName, enabled:enabled, freq:queryFrequency}"
    
  2. Identity is stable. Edit the rule’s description, push, redeploy, and confirm the rule’s resource ID is unchanged - proving it was updated, not recreated.

  3. KQL gate actually fails. Introduce a deliberate typo (SiginLogs) in a branch and confirm the PR check goes red before merge.

  4. Promotion gate holds. Confirm a merge to main pauses for approval rather than deploying immediately.

  5. Rollback restores exactly. git revert the last change, redeploy, and confirm the rule returns to its prior definition with the same GUID.

  6. Drift is detected. Hand-edit the rule in the portal, run the what-if drift job, and confirm it flags the divergence.

Checklist

Going deeper

The Repositories feature, end to end

Sentinel’s first-party path to Git is Content management → Repositories. Connect a GitHub or Azure DevOps repo and Sentinel does three things at once: it registers an Entra app named Azure Sentinel Content Deployment App (suffixed with the repository ID), grants it access to the workspace’s resource group, and commits a deployment workflow (or pipeline) into your repo. From then on, every push deploys the changed content.

What the connector does not give you: a test stage, environment promotion, or approvals. It is a deployer, and that single fact is why the rest of this lesson builds a pipeline around it (or instead of it). A custom Azure DevOps pipeline that adds the missing gates mirrors the GitHub Actions one from section 5:

# azure-pipelines.yml - custom Azure DevOps pipeline (validate, then deploy)
trigger:
  branches:
    include:
      - main

pool:
  vmImage: ubuntu-latest

stages:
  - stage: validate
    jobs:
      - job: lint_and_test
        steps:
          - task: AzureCLI@2
            inputs:
              azureSubscription: sc-sentinel-oidc   # workload-identity federation service connection
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                for f in analytics-rules/*.bicep; do az bicep build --file "$f"; done
          - task: AzurePowerShell@5
            inputs:
              azureSubscription: sc-sentinel-oidc
              azurePowerShellVersion: latestVersion
              ScriptPath: tests/kql-validate.ps1
              ScriptArguments: -WorkspaceId $(DEV_WORKSPACE_ID)
  - stage: deploy
    dependsOn: validate
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: deploy_prod
        environment: sentinel-production   # ADO environment with required approvals
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  inputs:
                    azureSubscription: sc-sentinel-oidc
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      for f in analytics-rules/*.bicep; do
                        az deployment group create \
                          --resource-group rg-sec-sentinel-prod \
                          --template-file "$f" \
                          --parameters workspaceName=law-sentinel-prod \
                          --name "sentinel-deploy-$(Build.BuildId)"
                      done

The Azure DevOps environment: sentinel-production plays the same role GitHub’s environment: production does — it is where you attach required approvers so a human gates the prod deploy.

Every content type is a template

Detection-as-code is not limited to analytics rules. Nearly everything in Sentinel is an ARM resource under the Microsoft.SecurityInsights (or a neighbouring) provider, which is exactly why it is all deployable as Bicep/ARM:

Content type Resource type Notes
Scheduled / NRT analytics rule Microsoft.SecurityInsights/alertRules kind: Scheduled or NRT; the rule’s name is its stable GUID
Hunting query / saved search Microsoft.OperationalInsights/workspaces/savedSearches category Hunting Queries
Automation rule Microsoft.SecurityInsights/automationRules ordered, no-code triage
Playbook Microsoft.Logic/workflows a Logic App; deploy the workflow plus its API connections
Workbook Microsoft.Insights/workbooks serializedData holds the gallery template JSON
Watchlist Microsoft.SecurityInsights/watchlists (+ watchlistItems) reference data joined via _GetWatchlist()
Data connector Microsoft.SecurityInsights/dataConnectors some are ARM-deployable, some remain portal-only

Author them all the same way: Bicep with loadTextContent() for the KQL/JSON body, a stable name, and a parameter file per environment. Hunting queries and workbooks are the easy wins to bring under version control first — they carry no incident lineage, so there is no GUID-stability risk if you get a name wrong early.

Content Hub solutions as your baseline

Most production content does not begin as your code — it begins as a Content Hub solution: a packaged, versioned ARM template bundling a connector, analytics-rule templates, hunting queries, workbooks, and playbooks for a product (Microsoft Entra ID, Threat Intelligence, a vendor firewall). Installing a solution creates rule templates; you then instantiate a template into a live rule. Treat solutions as the baseline you build on, not content you edit in place:

The out-of-the-box content is a head start, not a strategy. The repo is where breadth becomes a maintained, reviewable estate.

CI validation beyond | take 0

The | take 0 engine probe from section 3 is the KQL unit test — it proves a query binds to the schema. Layer template-level validation on top:

# tests/arm-ttk.ps1 - lint the compiled ARM with the ARM Template Toolkit (arm-ttk)
Import-Module ./arm-ttk/arm-ttk.psd1
$failed = @()
Get-ChildItem ./analytics-rules -Filter '*.bicep' | ForEach-Object {
    $json = "$($_.BaseName).json"
    az bicep build --file $_.FullName --outfile $json
    $results = Test-AzTemplate -TemplatePath $json
    if ($results | Where-Object { -not $_.Passed }) {
        Write-Host "FAIL  $($_.Name)"
        $failed += $_.Name
    }
}
if ($failed.Count -gt 0) { throw "ARM-TTK failed: $($failed -join ', ')" }

Parameterizing per environment

Never bake an environment value into a rule body. The rule module takes workspaceName (and thresholds, lookbacks) as parameters; each environment supplies its own parameter file:

impossible-travel.parameters-dev.json    -> law-sentinel-dev,  threshold 0
impossible-travel.parameters-prod.json   -> law-sentinel-prod, threshold 0

For the Repositories connector, parameterfilemappings (keyed by workspace ID, section 4) picks the right file per workspace. For your own pipeline, pass --parameters @<file> in the deploy step, or drive it from the workspaces.json manifest (section 6). The rule logic is identical in every workspace; only the parameters differ — and that is precisely what makes “identical content everywhere” both true and auditable. If you find yourself editing the rule body per environment, you have reintroduced the drift you were trying to kill.

The SecDevOps loop and peer review

Detection-as-code is SecDevOps: the same author → review → test → deploy → monitor loop developers use, applied to detections. The peer review is the heart of it — a second analyst reads the KQL diff and asks the questions the portal never forces:

Push as much as possible down into CI checks (so review focuses on logic, not hygiene), and keep a short detection review checklist in the PR template. Monitoring closes the loop: track each rule’s alert volume and true-positive rate after deploy, and feed the tuning back as the next pull request. The rule you shipped is a hypothesis; the next PR is the correction.

MITRE ATT&CK mapping and coverage

Every rule should carry tactics and techniques. Beyond tagging individual rules, aggregate them into a coverage map so the gaps become visible. Export your rule set and summarise by technique:

# count how many rules cover each technique, from the compiled ARM
jq -r '.resources[].properties.techniques[]?' analytics-rules/*.json \
  | sort | uniq -c | sort -rn

Feed that into a MITRE ATT&CK Navigator layer JSON to visualise coverage (dark = well covered, blank = a gap). Sentinel’s own MITRE ATT&CK blade shows coverage from active rules and templates; detection-as-code lets you compute the same thing in CI and fail a release if coverage for a priority technique regresses. Coverage is a portfolio metric, not a per-rule one — the map is what tells you where to write the next detection, rather than adding a fifth rule for a technique you already cover six ways.

Drift: portal edits versus Git

The exact failure mode detection-as-code exists to kill: someone tunes a rule in the portal, and now the live rule no longer matches the repo. Two defences, used together:

  1. Detect it. A scheduled az deployment group what-if (section 8 and the enterprise scenario) redeploys the repo in preview mode and flags any rule whose live state differs from the template. Run it nightly per workspace and alert on any change type other than NoChange / Ignore.
  2. Prevent it. Make production drift hard: grant analysts Microsoft Sentinel Responder (triage incidents) rather than Contributor in prod, so they cannot edit rule definitions in the portal at all. Rule changes then have exactly one path — a pull request. The connector’s smart deployments helps too: because it redeploys from the branch, a manual edit is overwritten on the next push (and flagged by drift detection in the window between).

Drift detection plus least-privilege RBAC is what lets you tell an auditor a detection ran unmodified: you hold both the negative proof (no drift alert fired) and the positive proof (the git-tagged deploy reconciles to one approved commit).

Sigma to KQL conversion

You do not have to write every detection from scratch. Sigma is a vendor-neutral, YAML-based detection format with a large public rule library. Convert Sigma rules to KQL with pySigma (the sigma-cli tool) and the Kusto/Sentinel backend, then wrap the output as a Bicep rule in your repo. A Sigma rule is just structured YAML:

# sigma/whoami-recon.yml
title: Whoami Execution for Reconnaissance
id: 8de89f4b-e4e5-4e3a-9d8b-1f7c2a0b9e11
status: experimental
description: Detects execution of whoami.exe, a common post-exploitation discovery command.
references:
  - https://attack.mitre.org/techniques/T1033/
author: SOC Team
date: 2026/01/10
logsource:
  category: process_creation
  product: windows
detection:
  selection:
    Image|endswith: '\whoami.exe'
  condition: selection
falsepositives:
  - Legitimate administrative activity
level: low
tags:
  - attack.discovery
  - attack.t1033

Convert it with the Kusto backend, which targets Sentinel/Defender tables via a pipeline that maps Sigma fields to the right schema:

pip install sigma-cli pysigma-backend-kusto
# emit KQL for Sentinel (ASIM) or Defender XDR tables
sigma convert --target kusto --pipeline sentinel_asim sigma/whoami-recon.yml

Treat the generated KQL as a starting point: review it, entity-map it, tune the false positives, then commit it as a rule with its own stable GUID. Sigma gives you breadth and a shared community language; your repo gives the result an identity, tests, and a deployment path. Field mappings are backend-specific and evolve, so always validate the emitted KQL with the | take 0 probe before you trust it — a converted rule that binds is the floor, not the finish line.

Practice challenges

Work these in a lab subscription and a scratch repo. No live runs are shown — every command and template is schema-correct and current; replace each <placeholder> with your own value. Try each one first, then expand the solution.

1. Give a rule a stable identity (beginner). Generate a GUID and set it as a rule’s resource name, and explain in one sentence why it must never change afterwards.

<details> <summary>Show solution</summary>

# any of these generates a v4 GUID
uuidgen                         # macOS / Linux
pwsh -c "[guid]::NewGuid()"     # PowerShell

Put it in the file and keep it forever:

ruleId: 'b2f1c7a4-9d3e-4a8b-bb21-7e5d4c0a1f93'  // stable GUID = the rule's identity in Azure

Why: the resource name is the rule’s identity — an idempotent deploy updates it in place and keeps incident history; change the GUID and Azure deletes the old rule and creates a new one, resetting alert lineage. </details>

2. Get KQL out of the JSON prison (beginner). Move an inline KQL string out of a rule template and into its own .kql file, then load it in Bicep.

<details> <summary>Show solution</summary>

// in the rule module
param query string

// in the per-rule file
query: loadTextContent('./impossible-travel.kql')

Why: KQL in its own file is diffable, lintable, and syntax-highlighted by your editor; trapped as an escaped one-line JSON string it is unreviewable and error-prone. </details>

3. Wire the engine as your KQL unit test (intermediate). Add a validation step that fails the build on a KQL parse error, without scanning any data.

<details> <summary>Show solution</summary>

# cheapest authoritative gate: submit each query with | take 0
$probe = "$(Get-Content ./analytics-rules/impossible-travel.kql -Raw)`n| take 0"
Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $probe -ErrorAction Stop

Run it over every *.kql in CI and throw if any query fails (the full loop is tests/kql-validate.ps1 in section 3).

Why: there is no offline KQL compiler — the Log Analytics engine is the only authoritative validator. | take 0 makes it parse, bind columns, and resolve functions without scanning data or costing query volume. </details>

4. Constrain the connector (intermediate → advanced). You are using the Repositories connector but must keep drafts and tests out of prod and target a prod parameter file to one workspace. Write the sentinel-deployment.config.

<details> <summary>Show solution</summary>

{
  "excludecontentfiles": [
    "analytics-rules/experimental-draft.bicep",
    "tests"
  ],
  "parameterfilemappings": {
    "11111111-1111-1111-1111-111111111111": {
      "analytics-rules/impossible-travel.bicep": "analytics-rules/impossible-travel.parameters-prod.json"
    }
  }
}

Why: the connector deploys whatever is on the branch with no test stage, so excludecontentfiles is your only guard against shipping drafts, and the workspace-ID-keyed parameterfilemappings is how one repo sends the right per-environment values to each workspace. Keys are lowercase and paths use forward slashes only. </details>

5. Adopt a community detection via Sigma (advanced). Convert the whoami Sigma rule to KQL and wrap it as a repo rule with its own GUID and MITRE tags.

<details> <summary>Show solution</summary>

sigma convert --target kusto --pipeline microsoft_xdr sigma/whoami-recon.yml > whoami-recon.kql
// analytics-rules/whoami-recon.bicep
param workspaceName string
module r '../shared/rule.bicep' = {
  name: 'whoami-recon'
  params: {
    workspaceName: workspaceName
    ruleId: '7c19a0e2-4b6d-4f1a-9c33-2d8e5f0b7a44'   // new stable GUID - yours, not the solution's
    displayName: 'Whoami execution - reconnaissance'
    severity: 'Low'
    query: loadTextContent('./whoami-recon.kql')
    tactics: ['Discovery']
    techniques: ['T1033']
  }
}

Why: Sigma gives you a vetted, vendor-neutral detection for free; wrapping it with your own GUID, entity mapping, and MITRE tags turns borrowed logic into a rule your pipeline owns, tests, and can roll back. </details>

6. Prove no drift, org-wide (advanced). Write a nightly check that runs what-if for every workspace in workspaces.json and exits non-zero on any drift.

<details> <summary>Show solution</summary>

#!/usr/bin/env bash
set -euo pipefail
drift=0
jq -c '.[]' workspaces.json | while read -r ws; do
  rg=$(echo "$ws" | jq -r '.rg'); name=$(echo "$ws" | jq -r '.ws')
  az account set --subscription "$(echo "$ws" | jq -r '.subId')"
  for f in analytics-rules/*.bicep; do
    az deployment group what-if \
      --resource-group "$rg" --template-file "$f" \
      --parameters workspaceName="$name" --no-pretty-print | tee whatif.json >/dev/null
    if jq -e '.changes[] | select(.changeType != "NoChange" and .changeType != "Ignore")' whatif.json >/dev/null; then
      echo "DRIFT in $name :: $(basename "$f")"; drift=1
    fi
  done
done
exit $drift

Why: what-if is the diff between the repo and the live workspace; failing the job on any change type other than NoChange/Ignore turns “prove this rule was not tampered with” into a one-line, scheduled audit control. </details>

Common beginner mistakes

Glossary

Microsoft-Sentineldetection-as-codeCI-CDanalytics-rulescontent-hubGitOps
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments