Detection-as-Code Pipeline with Git and CI/CD
In 2025, only 35% of security teams use Detection-as-Code systematically, despite 63% of professionals saying they would like to adopt it regularly. This gap between intention and practice reveals a real problem: most organizations do not know how to build a robust DaC pipeline that manages the entire lifecycle of a detection rule, from creation to production deployment.
Detection-as-Code (DaC) applies software engineering practices to the security domain: version control, code review, automated testing, CI/CD pipelines, and controlled deployment. The result is a drastic reduction in the time between discovering a new attack technique and deploying an effective detection, with rule quality that is measurable and improvable over time.
What You Will Learn
- DaC repository structure: directory layout, naming convention, schema
- Git workflow: branching strategy, commit convention, pull request review
- GitHub Actions pipeline: lint, validate, test, convert, deploy
- Automated testing with synthetic logs: positive and negative fixtures
- Secure deployment to Splunk, Elastic, and Sentinel via API
- Automatic rollback and incident management post-deploy
- Quality metrics: coverage, false positive rate, deployment frequency
Why Detection-as-Code is Fundamental
Before DaC, the typical process for updating a rule in a SIEM was: access the web console, find the rule, manually modify it, save it, and hope nothing breaks. No previous version, no tests, no peer review, no change traceability. When a rule started generating thousands of false positives at 3 AM, it was impossible to know who did what and when. DaC transforms this chaotic process into something systematic:
- Every rule is a text file versioned in Git
- Every change goes through a pull request with mandatory review
- Before merging, the pipeline runs automated tests on synthetic logs
- Deployment only happens if all tests pass
- If problems arise, rollback is a simple
git revert
DaC Repository Structure
detection-as-code/
├── .github/
│ ├── workflows/
│ │ ├── pr-validation.yml # Validate on every PR
│ │ ├── main-deploy.yml # Deploy on merge to main
│ │ └── nightly-test.yml # Nightly test on staging
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── CODEOWNERS
├── rules/
│ ├── windows/
│ │ ├── credential_access/
│ │ ├── lateral_movement/
│ │ └── persistence/
│ ├── linux/
│ ├── cloud/
│ │ ├── aws/
│ │ └── azure/
│ └── network/
├── tests/
│ ├── fixtures/
│ │ └── windows/credential_access/
│ │ └── cred_mimikatz_lsass/
│ │ ├── positive/
│ │ └── negative/
│ └── unit/test_sigma_rules.py
├── pipelines/
│ ├── splunk_enterprise.yml
│ └── elastic_ecs.yml
├── scripts/
│ ├── validate.py
│ ├── convert.py
│ └── deploy_splunk.py
└── Makefile
Naming Convention
# Pattern: <tactic_prefix>_<technique_name>_<context>.yml
cred_mimikatz_lsass.yml # credential_access
lat_psexec_remote_execution.yml # lateral_movement
per_scheduled_task_creation.yml # persistence
def_timestomp_modification.yml # defense_evasion
exe_powershell_encoded.yml # execution
exf_dns_tunneling.yml # exfiltration
c2_http_beaconing.yml # command_and_control
Complete GitHub Actions CI/CD Pipeline
# .github/workflows/pr-validation.yml
name: Detection Rule Validation
on:
pull_request:
paths:
- 'rules/**/*.yml'
- 'tests/**'
jobs:
validate-schema:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pyyaml jsonschema sigma-cli
- name: Validate Sigma schemas
run: python scripts/validate.py ./rules --schema schemas/sigma_rule_schema.json
- name: Check UUID uniqueness
run: python scripts/check_uuid_uniqueness.py ./rules
unit-tests:
needs: validate-schema
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pytest pyyaml sigma-cli pySigma-backend-splunk
- name: Run detection tests
run: pytest tests/unit/ -v --tb=short --junit-xml=test-results.xml
convert-validate:
needs: unit-tests
runs-on: ubuntu-latest
strategy:
matrix:
target: [splunk, elasticsearch, microsoft365defender]
steps:
- uses: actions/checkout@v4
- run: pip install sigma-cli pySigma-backend-splunk pySigma-backend-elasticsearch
- name: Test conversion to ${{ matrix.target }}
run: sigma convert -t ${{ matrix.target }} rules/
# .github/workflows/main-deploy.yml
name: Deploy Detection Rules to Production
on:
push:
branches: [main]
paths: ['rules/**/*.yml']
jobs:
deploy-splunk:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Identify changed rules
id: changed-rules
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'rules/**/*.yml')
echo "files=$CHANGED" >> $GITHUB_OUTPUT
- name: Convert and deploy
env:
SPLUNK_HOST: ${{ secrets.SPLUNK_HOST }}
SPLUNK_TOKEN: ${{ secrets.SPLUNK_TOKEN }}
run: |
pip install sigma-cli pySigma-backend-splunk requests
echo "${{ steps.changed-rules.outputs.files }}" | while read rule; do
[ -f "$rule" ] && sigma convert -t splunk -p splunk_windows "$rule" \
>> converted.spl
done
python scripts/deploy_splunk.py \
--rules-file converted.spl \
--host "$SPLUNK_HOST" \
--token "$SPLUNK_TOKEN" \
--dry-run false
Python Testing Framework for Detection Rules
#!/usr/bin/env python3
"""Detection Rule Test Framework."""
import json
import yaml
import pytest
from pathlib import Path
from typing import NamedTuple
class TestCase(NamedTuple):
rule_path: Path
fixture_path: Path
should_match: bool
def load_test_cases() -> list[TestCase]:
"""Load all test cases from the directory structure."""
test_cases = []
rules_dir = Path("rules")
fixtures_dir = Path("tests/fixtures")
for rule_file in rules_dir.rglob("*.yml"):
rel_path = rule_file.relative_to(rules_dir)
fixture_dir = fixtures_dir / rel_path.parent / rule_file.stem
if fixture_dir.exists():
for pos_file in (fixture_dir / "positive").glob("*.json"):
test_cases.append(TestCase(rule_file, pos_file, True))
for neg_file in (fixture_dir / "negative").glob("*.json"):
test_cases.append(TestCase(rule_file, neg_file, False))
return test_cases
class TestDetectionRules:
@pytest.mark.parametrize("test_case", load_test_cases())
def test_rule_against_fixture(self, test_case: TestCase):
rule_path, fixture_path, should_match = test_case
with open(fixture_path) as f:
log_event = json.load(f)
matched = evaluate_sigma_rule(rule_path, log_event)
if should_match:
assert matched, (
f"Rule {rule_path.name} SHOULD match {fixture_path.name} "
f"but did NOT.\nEvent: {json.dumps(log_event, indent=2)}"
)
else:
assert not matched, (
f"Rule {rule_path.name} should NOT match {fixture_path.name} "
f"but DID (false positive).\n"
f"Event: {json.dumps(log_event, indent=2)}"
)
def test_no_duplicate_ids(self):
"""Ensure no duplicate UUIDs exist in the repository."""
seen_ids = {}
duplicates = []
for rule_file in Path("rules").rglob("*.yml"):
with open(rule_file) as f:
rule = yaml.safe_load(f)
rule_id = rule.get('id', '')
if rule_id in seen_ids:
duplicates.append(f"{rule_file} duplicates {seen_ids[rule_id]}")
else:
seen_ids[rule_id] = rule_file
assert not duplicates, "Duplicate IDs:\n" + "\n".join(duplicates)
DaC Quality Metrics
| Metric | Definition | Target |
|---|---|---|
| Detection Coverage | % ATT&CK techniques with stable rule | > 60% |
| Test Coverage | % rules with positive + negative tests | 100% |
| False Positive Rate | FP alerts / total alerts per week | < 10% |
| Deployment Frequency | Merges with new/updated rules per week | > 3/week |
| Time to Detection (TTD) | Time from technique published to rule deployed | < 48h for high severity |
Anti-Patterns to Avoid
- Deploy without tests: even in emergencies, run at least one smoke test
- Hardcoded secrets: always use GitHub Secrets, never hardcoded credentials
- Silent pipeline failures: a failed deploy with no notification is dangerous
- Rules without owners: use CODEOWNERS to assign technical responsibility
- Only positive tests: without negative fixtures you cannot detect false positives
Conclusions
Detection-as-Code is not just a tool change, it is a mindset change: detections are code, and as such must be tested, versioned, reviewed, and deployed systematically. A mature DaC pipeline reduces Time-to-Detection, improves rule quality, and allows scaling the detection program without proportionally increasing headcount.
Next Article in the Series
In the next article we will see how to integrate MITRE ATT&CK into the DaC workflow to automatically map coverage gaps and prioritize new detections based on real risk.
Related deep dive: 01 - Sigma Rules: Logica di Detection Universale e Conversione SIEM.
Related deep dive: 03 - MITRE ATT&CK Integration: Mappare Coverage Gap Programmaticamente.
Frequently asked questions
What percentage of security teams use Detection-as-Code systematically?
Only 35% of security teams use Detection-as-Code systematically.
What is the main problem that prevents organizations from adopting a robust Detection-as-Code pipeline?
Most organizations do not know how to build a robust DaC pipeline that manages the entire lifecycle of a detection rule, from creation to production deployment.
How does Detection-as-Code reduce the time between discovering a new attack technique and deploying an effective detection?
The result is a drastic reduction in the time between discovering a new attack technique and deploying an effective detection.
What is the main benefit of adopting a mature Detection-as-Code pipeline?
A mature DaC pipeline reduces Time-to-Detection, improves rule quality, and allows scaling the detection program without proportionally increasing headcount.
Commenti
Caricamento commenti...
Sign in to comment