Lesson 4 of 5 · 25 min read

CI/CD for Detection Rules — Automate Lint, Test, Convert, Deploy

The CI/CD pipeline is what makes detection-as-code real — it replaces “hope the rule works” with “the rule passed automated validation and was deployed consistently.” This lesson builds a complete GitHub Actions pipeline for a Sigma detection repository.


The Pipeline Architecture

On: PR opened

  ├─── Job: lint
  │     └─ sigma check all changed rules

  ├─── Job: convert (depends on: lint)
  │     ├─ sigma convert -t splunk -p sysmon
  │     ├─ sigma convert -t elasticsearch -p ecs-windows
  │     └─ sigma convert -t microsoft365defender

  └─── Job: test (depends on: convert)
        ├─ Run TP tests → assert results ≥ 1
        └─ Run FP tests → assert results = 0

On: push to main (merge)

  ├─── Job: convert-all
  │     └─ Full conversion for all target backends

  ├─── Job: deploy-dev
  │     └─ Push converted rules to dev SIEM

  ├─── Job: deploy-staging (depends on: deploy-dev + integration test pass)
  │     └─ Push to staging SIEM (shadow mode)

  └─── Job: deploy-prod (requires: manual approval)
        └─ Push to production SIEM

The GitHub Actions Workflow

PR Checks (.github/workflows/pr-checks.yml)

name: Detection Rule PR Checks

on:
  pull_request:
    branches: [ main ]
    paths:
      - 'rules/**'
      - 'tests/**'
      - 'pipelines/**'

jobs:
  lint:
    name: Lint Sigma Rules
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install sigma-cli
        run: pip install sigma-cli pySigma-backend-splunk pySigma-backend-elasticsearch

      - name: Get changed rule files
        id: changed-files
        uses: tj-actions/changed-files@v41
        with:
          files: rules/**/*.yml

      - name: Lint changed rules
        if: steps.changed-files.outputs.any_changed == 'true'
        run: |
          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            echo "Checking: $file"
            sigma check "$file"
          done

  convert:
    name: Test Sigma Conversion
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: pip install sigma-cli pySigma-backend-splunk pySigma-backend-elasticsearch pySigma-backend-microsoft365defender

      - name: Get changed rule files
        id: changed-files
        uses: tj-actions/changed-files@v41
        with:
          files: rules/**/*.yml

      - name: Convert to all backends
        if: steps.changed-files.outputs.any_changed == 'true'
        run: |
          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            echo "Converting: $file"
            sigma convert -t splunk -p sysmon "$file" > /dev/null
            sigma convert -t elasticsearch -p ecs-windows "$file" > /dev/null
            sigma convert -t microsoft365defender "$file" > /dev/null
            echo "  ✓ All backends converted successfully"
          done

  test:
    name: Run Rule Tests
    runs-on: ubuntu-latest
    needs: convert
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: pip install sigma-cli sigma-testing pySigma-backend-test

      - name: Find test files for changed rules
        id: changed-files
        uses: tj-actions/changed-files@v41
        with:
          files: rules/**/*.yml

      - name: Run tests
        if: steps.changed-files.outputs.any_changed == 'true'
        run: |
          for rule in ${{ steps.changed-files.outputs.all_changed_files }}; do
            # Derive test file path from rule path
            rule_name=$(basename "$rule" .yml)
            test_dir=$(dirname "$rule" | sed 's|rules/|tests/|')
            test_file="${test_dir}/${rule_name}.test.yml"
            
            if [ -f "$test_file" ]; then
              echo "Testing: $rule → $test_file"
              sigma test "$test_file"
            else
              echo "WARNING: No test file found for $rule"
              # Optionally fail if test file is required:
              # exit 1
            fi
          done

Deployment (.github/workflows/deploy.yml)

name: Deploy Detection Rules

on:
  push:
    branches: [ main ]
    paths:
      - 'rules/**'

jobs:
  convert-all:
    name: Convert All Changed Rules
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # need previous commit to detect changes

      - name: Install dependencies
        run: pip install sigma-cli pySigma-backend-splunk pySigma-backend-elasticsearch

      - name: Convert changed rules to all targets
        run: |
          git diff --name-only HEAD~1 -- 'rules/**/*.yml' | while read file; do
            sigma convert -t splunk -p sysmon "$file" > \
              "converted/splunk/$(basename $file .yml).spl"
            sigma convert -t elasticsearch -p ecs-windows "$file" > \
              "converted/elastic/$(basename $file .yml).ndjson"
          done

  deploy-dev:
    name: Deploy to Dev SIEM
    runs-on: ubuntu-latest
    needs: convert-all
    environment: dev
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Splunk Dev
        env:
          SPLUNK_URL: ${{ secrets.SPLUNK_DEV_URL }}
          SPLUNK_TOKEN: ${{ secrets.SPLUNK_DEV_TOKEN }}
        run: |
          python scripts/deploy_splunk.py \
            --url "$SPLUNK_URL" \
            --token "$SPLUNK_TOKEN" \
            --rules-dir converted/splunk/

  deploy-staging:
    name: Deploy to Staging (Shadow Mode)
    runs-on: ubuntu-latest
    needs: deploy-dev
    environment: staging    # GitHub environment with required reviewers
    steps:
      - name: Deploy to staging SIEM
        # ... similar to dev deploy, but targets staging SIEM

  deploy-prod:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment:
      name: production
      # Requires manual approval in GitHub → Settings → Environments
    steps:
      - name: Deploy to production SIEM
        # ... targets production SIEM

Branch Protection Configuration

In GitHub → Settings → Branches → Add protection rule for main:

✓ Require a pull request before merging
  ✓ Require approvals: 1
  ✓ Dismiss stale PR approvals when new commits are pushed
  ✓ Require review from Code Owners

✓ Require status checks to pass before merging
  Required status checks:
    - lint
    - convert
    - test

✓ Require branches to be up to date before merging
✓ Restrict who can push to matching branches (detection team leads only)
✗ Allow force pushes (DISABLED)
✗ Allow deletions (DISABLED)

With these settings: no PR can merge without all CI checks passing AND at least one reviewer approving. No one can push directly to main. This technically enforces the detection-as-code policy.


The CODEOWNERS File

Automatically assign reviewers based on which rules changed:

# .github/CODEOWNERS

# Default: require detection-team-leads review for everything
*                                   @detection-team/leads

# Cloud rules require cloud security team review
rules/cloud/aws/**                  @detection-team/leads @cloud-security
rules/cloud/azure/**                @detection-team/leads @cloud-security

# Pipelines changes require senior engineer
pipelines/**                        @detection-team/senior-engineers

Any PR touching cloud rules automatically requests review from both the detection team lead AND the cloud security team. Pipeline changes (which affect all rule conversions) require senior engineers.

GitHub Actions— GitHub Branch Protection— GitHub