Azure Sentinel and GitHub: Detection-as-Code That Actually Ships
Most Sentinel deployments we inherit have their analytics rules living exclusively in the Azure portal. No version control. No change history beyond whatever the workspace audit log captured before someone rotated a credential. Someone deletes a rule at 2am and you find out when the alert stops firing three weeks later.
Wiring GitHub into Sentinel solves two separate problems. You get GitHub's own activity as a threat-intel source. And you get your detection content under version control with a real CI/CD deployment path. This note covers both, with the actual commands and config you need to make it work.
What the GitHub Connector Actually Streams
Microsoft Sentinel has a native GitHub connector that pulls from GitHub Audit Log Streaming and writes events to a GitHubAuditLog_CL custom log table. Authentication uses a GitHub PAT scoped to read:audit_log. The connector polls every five minutes.
What lands in that table:
- Repository visibility changes (private to public is the one you actually care about)
- Branch protection rule modifications and deletions
- OAuth app authorization and revocation
- Secret scanning alert dismissals
- Actions workflow run events
- Outside collaborator additions
At scale, the native connector starts struggling around 50,000 events per day. Larger organizations should configure GitHub Audit Log Streaming directly to an Azure Event Hub and ingest from there. The Event Hub path bypasses the polling rate limit entirely. Setup: in the Sentinel data connectors blade, search "GitHub", install the content hub solution, supply the PAT and org name. You should see a heartbeat within 15 minutes if the scopes are right.
KQL for GitHub Threat Signals
Once events flow into GitHubAuditLog_CL, a few queries pay off immediately. Repo visibility flips to public:
GitHubAuditLog_CL
| where TimeGenerated > ago(1d)
| where action_s == "repo.access"
| extend RepoName = tostring(repo_s)
| extend Actor = tostring(actor_s)
| extend Visibility = tostring(data_visibility_s)
| where Visibility == "public"
| project TimeGenerated, Actor, RepoName, Visibility
| sort by TimeGenerated desc
Branch protection removed from a protected branch:
GitHubAuditLog_CL
| where TimeGenerated > ago(7d)
| where action_s == "protected_branch.destroy"
| extend Branch = tostring(data_branch_protection_rule_name_s)
| extend Actor = tostring(actor_s)
| project TimeGenerated, Actor, Branch, repo_s
These are baseline queries, not finished analytics rules. The production version needs a threshold, entity mappings, and incident grouping. Getting the raw shape right first is the point.
Storing Analytics Rules in GitHub
The cleaner pattern treats your Sentinel workspace content, analytics rules, hunting queries, workbooks, as files in a GitHub repository. Microsoft publishes ARM templates for Sentinel resources, but we use Bicep now because the diff output is readable by people. One file per rule, filename matching the rule display name slugified. A rules/ directory holds production rules; a staging/ directory holds rules under test.
A single analytics rule in Bicep looks like this:
resource githubRepoPublicRule 'Microsoft.SecurityInsights/alertRules@2023-02-01-preview' = {
name: 'github-repo-made-public'
kind: 'Scheduled'
scope: workspace
properties: {
displayName: 'GitHub: Repository Made Public'
description: 'Fires when a private repository is changed to public visibility.'
severity: 'Medium'
enabled: true
query: '''
GitHubAuditLog_CL
| where action_s == "repo.access"
| where data_visibility_s == "public"
| project TimeGenerated, Actor = actor_s, Repo = repo_s
'''
queryFrequency: 'PT1H'
queryPeriod: 'PT1H'
triggerOperator: 'GreaterThan'
triggerThreshold: 0
tactics: ['Collection']
entityMappings: [
{
entityType: 'Account'
fieldMappings: [{ identifier: 'Name', columnName: 'Actor' }]
}
]
}
}
PRs into main deploy to production. PRs into staging deploy to a non-production workspace. That branch model gives you a review gate on every detection change, which is the part that matters for a detection engineering program that needs to pass an audit.
GitHub Actions Deployment Pipeline
The Actions workflow handles deployment. We use OIDC federation so no long-lived secrets sit in GitHub Actions secrets storage.
name: Deploy Sentinel Rules
on:
push:
branches: [main]
paths:
- 'rules/**'
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy analytics rules
run: |
az deployment group create \
--resource-group ${{ vars.SENTINEL_RG }} \
--template-file rules/main.bicep \
--parameters workspaceName=${{ vars.SENTINEL_WORKSPACE }}
The rules/main.bicep file is a module aggregator that imports each individual rule file. Adding a rule means creating one .bicep file and one module line in the aggregator. The deployment is idempotent; rerunning it either creates or updates, it never errors on an existing rule.
Watchlists From GitHub-Managed CSVs
Sentinel watchlists work well for VIP account lists, IP allowlists, and known-bad indicators. Storing the underlying CSVs in GitHub gives you the same PR-review workflow for data changes that you have for rule changes.
A CSV lives in watchlists/vip_accounts.csv. An Actions step uploads it to Sentinel on merge via the REST API. Then KQL joins against it:
let VIPAccounts = (
_GetWatchlist('VIPAccounts')
| project AccountName = SearchKey
);
GitHubAuditLog_CL
| where actor_s in (VIPAccounts)
| where action_s startswith "org."
| project TimeGenerated, Actor = actor_s, Action = action_s
The join makes triage faster. Instead of looking up whether jsmith is a service account or an executive mid-incident, the watchlist answers it in the query.
What Breaks in Practice
The GitHubAuditLog_CL schema is not stable. GitHub has added and renamed fields without announcing it. Any KQL that references a specific _s-suffixed column directly should use tostring() safety wrapping rather than assuming the column always exists with the same name.
GitHub Enterprise Cloud and GitHub Enterprise Server have different audit log schemas. The connector documentation does not make this obvious. If you run GHES on-premises and stream via Event Hub, the action_s naming conventions differ from cloud-hosted organizations. Test your queries against actual event samples before building production rules.
OIDC federation between GitHub Actions and Azure requires the exact federated credential subject claim. For a push to main, the subject is repo:<org>/<repo>:ref:refs/heads/main. Getting this wrong produces a silent authentication failure that surfaces as a permissions error, not a token error. Pin the subject to the exact branch pattern; avoid wildcard subjects in production.
Finally, Sentinel ingestion costs real money. A large organization's GitHub audit logs, especially one with heavy Actions CI usage, can add 5 to 15 GB per day to your Log Analytics bill. Use a data collection rule with a transformation to drop workflow_job.queued and workflow_job.in_progress events if you only care about completions and failures. The filter alone can cut ingestion volume by 40 to 60 percent on CI-heavy organizations.
Next Steps
If you are standing up this pipeline from scratch or trying to retrofit it onto an existing Sentinel deployment, reach out at /contact and we can scope what the migration actually looks like for your workspace size.
