Detection Rules as Code for SIEM: Git, Testing, and Safe Deployment
This guide is for developers who need a repeatable way to manage SIEM detection content without hand-editing rules in a UI. You will set up a Git-backed repository, add schema validation and test fixtures, run CI checks, and deploy rules to your SIEM through an API with predictable rollback behavior.
TL;DR — Put detection rules in Git, validate them with a JSON Schema and test fixtures in CI, then deploy only the changed rules through your SIEM API from a tagged release or protected branch. The most common failure is skipping stable rule IDs and drift detection, which creates duplicate rules or overwrites analyst-tuned settings. Reading time: ~5 min
Goal
When you finish, your team will have a Git repository containing versioned SIEM detection rules, automated validation and test execution in CI, and a deployment job that creates or updates rules through an API using stable IDs so changes are traceable, reviewable, and reversible.
Prerequisites
- Git access to your source control system and permission to create protected branches/tags
- Access to your SIEM rule-management API with a token that can read and write rules
git >= 2.40— check with:
git --version
jq >= 1.6— check with:
jq --version
curl >= 8.0— check with:
curl --version | head -n 1
python >= 3.11andpip— check with:
python3 --version
pip --version
ajv-cli >= 5for JSON Schema validation:
npm install --global ajv-cli
ajv --version
- A repo path for rules, for example
detections/rules/ - Your SIEM API base URL, for example
https://siem.example.com/api - Your bearer token exported in the shell:
export SIEM_API_URL="https://siem.example.com/api"
export SIEM_TOKEN="replace-me"
Steps
Step 1: Create the repository layout
Run:
mkdir -p detections/{rules,tests,schemas,scripts,.github/workflows}
cat > detections/schemas/rule.schema.json <<'json'
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "name", "severity", "query", "enabled", "version"],
"properties": {
"id": {"type": "string", "pattern": "^[a-z0-9._-]+$"},
"name": {"type": "string", "minLength": 5},
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
"query": {"type": "string", "minLength": 10},
"enabled": {"type": "boolean"},
"version": {"type": "integer", "minimum": 1},
"tags": {"type": "array", "items": {"type": "string"}},
"schedule": {
"type": "object",
"required": ["interval_minutes", "lookback_minutes"],
"properties": {
"interval_minutes": {"type": "integer", "minimum": 1},
"lookback_minutes": {"type": "integer", "minimum": 1}
}
}
},
"additionalProperties": true
}
json
cat > detections/rules/suspicious-powershell.json <<'json'
{
"id": "endpoint.suspicious-powershell-encodedcommand",
"name": "Suspicious PowerShell EncodedCommand",
"severity": "high",
"query": "process_name = 'powershell.exe' AND command_line CONTAINS '-EncodedCommand'",
"enabled": true,
"version": 1,
"tags": ["endpoint", "powershell"],
"schedule": {"interval_minutes": 5, "lookback_minutes": 10}
}
json
cat > detections/tests/suspicious-powershell.pass.json <<'json'
{"process_name":"powershell.exe","command_line":"powershell.exe -EncodedCommand SQBtAHAA"}
json
cat > detections/tests/suspicious-powershell.fail.json <<'json'
{"process_name":"notepad.exe","command_line":"notepad.exe readme.txt"}
json
Success looks like detections/rules/, detections/tests/, and detections/schemas/ existing with the files above.
Step 2: Add local validation and test scripts
Run:
cat > detections/scripts/validate.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
ajv validate -s detections/schemas/rule.schema.json -d 'detections/rules/*.json'
bash detections/scripts/unique-ids.sh
bash detections/scripts/lint-queries.sh
bash detections/scripts/run-tests.sh
bash detections/scripts/check-drift.sh
bash
cat > detections/scripts/unique-ids.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
ids=$(jq -r '.id' detections/rules/*.json)
dupes=$(printf '%s
' "$ids" | sort | uniq -d)
if [ -n "$dupes" ]; then
echo "duplicate rule ids found:"
echo "$dupes"
exit 1
fi
bash
cat > detections/scripts/lint-queries.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
for f in detections/rules/*.json; do
jq -e '.query | length > 10' "$f" >/dev/null
jq -e '.schedule.lookback_minutes >= .schedule.interval_minutes' "$f" >/dev/null || {
echo "lookback_minutes must be >= interval_minutes in $f"
exit 1
}
done
bash
cat > detections/scripts/run-tests.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
jq -e '.process_name == "powershell.exe" and (.command_line | contains("-EncodedCommand"))' detections/tests/suspicious-powershell.pass.json >/dev/null
jq -e '.process_name == "powershell.exe" and (.command_line | contains("-EncodedCommand")) | not' detections/tests/suspicious-powershell.fail.json >/dev/null
bash
cat > detections/scripts/check-drift.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
for f in detections/rules/*.json; do
id=$(jq -r '.id' "$f")
http_code=$(curl -sS -o /tmp/rule.json -w '%{http_code}' \
-H "Authorization: Bearer $SIEM_TOKEN" \
"$SIEM_API_URL/rules/$id")
if [ "$http_code" = "200" ]; then
remote_version=$(jq -r '.version // 0' /tmp/rule.json)
local_version=$(jq -r '.version' "$f")
if [ "$local_version" -lt "$remote_version" ]; then
echo "local version $local_version is older than remote version $remote_version for $id"
exit 1
fi
elif [ "$http_code" != "404" ]; then
echo "unexpected API status $http_code while checking $id"
exit 1
fi
done
bash
chmod +x detections/scripts/*.sh
bash detections/scripts/validate.sh
Success looks like no output except ajv validation lines and a zero exit code.
Step 3: Commit to Git with stable IDs and review gates
Run:
git init
cat > .gitignore <<'gitignore'
.env
*.tmp
.gitignore
cat > .gitattributes <<'gitattributes'
*.json text eol=lf
gitattributes
git add detections .gitattributes .gitignore
git commit -m "Add detection rules as code baseline"
Success looks like a commit containing your rules, schema, tests, and scripts.
Step 4: Add CI validation on pull requests
Run:
cat > .github/workflows/detections-ci.yml <<'yaml'
name: detections-ci
on:
pull_request:
push:
branches: [ main ]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: npm install --global ajv-cli
- run: sudo apt-get update && sudo apt-get install -y jq
- env:
SIEM_API_URL: ${{ secrets.SIEM_API_URL }}
SIEM_TOKEN: ${{ secrets.SIEM_TOKEN }}
run: bash detections/scripts/validate.sh
yaml
git add .github/workflows/detections-ci.yml
git commit -m "Add CI validation for detection content"
Success looks like the CI job passing on the next push or pull request.
Step 5: Add deployment script for create/update by rule ID
⚠️ This step changes production detection content. Run it first against a non-production SIEM or a disabled rule set if your platform supports that.
Run:
cat > detections/scripts/deploy.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
changed_files=$(git diff --name-only "${1:-HEAD~1}" HEAD -- 'detections/rules/*.json')
for f in $changed_files; do
id=$(jq -r '.id' "$f")
payload=$(jq -c '.' "$f")
status=$(curl -sS -o /tmp/deploy.json -w '%{http_code}' \
-H "Authorization: Bearer $SIEM_TOKEN" \
-H 'Content-Type: application/json' \
-X GET "$SIEM_API_URL/rules/$id")
if [ "$status" = "200" ]; then
put_status=$(curl -sS -o /tmp/deploy.json -w '%{http_code}' \
-H "Authorization: Bearer $SIEM_TOKEN" \
-H 'Content-Type: application/json' \
-X PUT "$SIEM_API_URL/rules/$id" \
--data "$payload")
echo "UPDATED $id HTTP $put_status"
elif [ "$status" = "404" ]; then
post_status=$(curl -sS -o /tmp/deploy.json -w '%{http_code}' \
-H "Authorization: Bearer $SIEM_TOKEN" \
-H 'Content-Type: application/json' \
-X POST "$SIEM_API_URL/rules" \
--data "$payload")
echo "CREATED $id HTTP $post_status"
else
echo "FAILED precheck $id HTTP $status"
cat /tmp/deploy.json
exit 1
fi
done
bash
chmod +x detections/scripts/deploy.sh
bash detections/scripts/deploy.sh HEAD~1
Success looks like one line per changed rule, for example:
UPDATED endpoint.suspicious-powershell-encodedcommand HTTP 200
Step 6: Wire deployment to protected releases
Run:
git tag -a detections-v1.0.0 -m "detections release 1.0.0"
git push origin main --tags
Then add a release-only deployment workflow in your CI system that runs:
bash detections/scripts/validate.sh
bash detections/scripts/deploy.sh "$(git describe --tags --abbrev=0)^"
Success looks like deployment happening only from a tag or protected branch, not from every feature branch.
Verify it works
Run schema validation, test execution, drift check, and an API readback:
bash detections/scripts/validate.sh
id="endpoint.suspicious-powershell-encodedcommand"
curl -sS -H "Authorization: Bearer $SIEM_TOKEN" "$SIEM_API_URL/rules/$id" | jq '{id,name,severity,version,enabled}'
Expected output shape:
{
"id": "endpoint.suspicious-powershell-encodedcommand",
"name": "Suspicious PowerShell EncodedCommand",
"severity": "high",
"version": 1,
"enabled": true
}
If your SIEM supports rule execution previews, run the rule against the pass/fail fixtures and confirm the pass fixture matches and the fail fixture does not.
View as text
[Developer] ---- 1. git push/pr ----> [Repo] [Repo] ---- 2. validate/tests ----> [CI] [CI] ---- 3. tag/release deploy ----> [SIEM API] [SIEM API] ---- 4. readback verify ----> [Developer]
Common pitfalls
Duplicate rule IDs across files
Mistake: copying a rule file and changing only the name, not the id.
Symptom: deploy updates the wrong rule or your SIEM rejects the create with a duplicate-key error.
Fix: run bash detections/scripts/unique-ids.sh and change "id" to a globally unique stable value.
Local version lower than remote version
Mistake: editing an old branch after another change already bumped the rule in production.
Symptom: validation fails with local version 1 is older than remote version 2 for <id>.
Fix: pull the latest branch, increment "version", and redeploy from the current head.
Lookback shorter than interval
Mistake: setting lookback_minutes below interval_minutes.
Symptom: missed events between executions or a lint failure: lookback_minutes must be >= interval_minutes.
Fix: set "lookback_minutes" to the same value as or greater than "interval_minutes".
API token has read but not write scope
Mistake: using a token that can list rules but cannot create/update them.
Symptom: deploy precheck returns 200, then update returns 403 or 401 with a body like:
{"error":"forbidden","message":"insufficient_scope"}
Fix: issue a token with rule write permission and export it again with export SIEM_TOKEN="...".
Deploying every file instead of changed files
Mistake: posting the full rules directory on every run.
Symptom: slow deployments, noisy audit logs, and accidental overwrites of analyst-side tuning fields if your API treats omitted fields as null.
Fix: keep git diff --name-only <base> HEAD -- 'detections/rules/*.json' in the deploy script and send full canonical payloads only for changed rules.
Relying on UI-only edits after adopting rules as code
Mistake: analysts change production rules directly in the SIEM UI.
Symptom: the next deploy silently reverts those changes, or drift check starts failing.
Fix: stop direct production edits or export the UI change back into detections/rules/*.json before the next release.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI