CI/CD
RSigma is designed to drop into a detection-as-code workflow. The CLI surfaces that matter for CI are rule lint, rule validate, rule backtest, rule coverage, engine eval, and backend convert. Each exits with a structured code that lets CI runners distinguish “no findings, clean exit” from “the tool ran but reported findings” from “the tool could not run because of a configuration or rule error.”
This page covers the exit-code model, the fixture harness (rule backtest), the failure-controlling flags (--fail-on-detection, --fail-level), and copy-paste pipelines for GitHub Actions, GitLab CI, pre-commit, and a generic shell runner.
Exit codes
Every rsigma command uses the same four-code scheme. The split is the conventional CI one shared by tools like pylint and zizmor: distinguish a tool failure from a finding, so the lint/validate steps in a pipeline can be required while the detection-match step can be advisory.
| Code | Meaning | Triggered by |
|---|---|---|
0 |
Success. The tool ran cleanly. With --fail-on-detection, no detection or correlation fired. |
Default happy path. Also returned when rule lint produces findings below the --fail-level threshold. |
1 |
Findings. The tool ran cleanly but found something noteworthy. | eval --fail-on-detection with at least one match; rule lint --fail-level <X> with at least one finding at or above X. |
2 |
Rule error. The input rules could not be parsed, compiled, or converted. | rule validate with parse or compile errors; backend convert when conversion fails or every rule fails; engine eval and rule lint when the rules path itself cannot be read. |
3 |
Configuration error. A pipeline file could not be loaded, a CLI argument was invalid, or the tool was misconfigured. | Bad -p path, unknown -t backend, malformed --suppress duration, unreadable schema file. |
The exact source of truth is the exit_code module.
A few non-obvious behaviours worth pinning down:
engine evalexits0when a rule file contains a Sigma parse error (it logs a warning to stderr and continues with the rules that did load). Userule validateif you want a parse error to fail the build.engine evalexits0by default even when matches fire. Pass--fail-on-detectionif you want detections to fail the build.rule lintexits0for findings below--fail-level. The default threshold iserror, so a clean lint with only info/warning findings still returns0.rule backtestexits1on a failed expectation or, under--unexpected fail, on a rule firing with no covering expectation. A bad expectations file or a missing corpus path is exit3, and unreadable rules are exit2.
rule backtest for fixture suites
rule backtest is the recommended fixture harness. It replays a corpus (a file or a directory walked recursively), tallies how many times each rule fired, and diffs those counts against an expectations file. Unlike engine eval --fail-on-detection, which is corpus-global and passes when any rule fires, backtest asserts per rule, so a broken rule cannot be masked by an unrelated rule matching the same fixture.
Declare what each rule must do in an expectations file:
# ci/expectations.yml
defaults:
unexpected_detections: warn # fail | warn | ignore
expectations:
- rule: 5f0d7d3c-3aab-43fa-952f-8f7b2d966ee5 # positive fixture: must fire
at_least: 1
- rule: Suspicious Whoami Execution # negative fixture: must not fire
exactly: 0
Then run the whole suite in one command:
rsigma rule backtest -r rules/ --corpus ci/corpus/ --expectations ci/expectations.yml
Exit 1 means an expectation failed (or, under --unexpected fail, that a rule fired with no covering expectation, the false-positive signal on a benign corpus). Emit a JUnit report for CI annotation with --junit, and the full JSON report with --report:
rsigma rule backtest -r rules/ --corpus ci/corpus/ \
--expectations ci/expectations.yml --junit backtest.xml --unexpected fail
Correlation rules are asserted the same way, by id or title. See rule backtest for the full flag table, expectations schema, and report shape.
rule coverage for ATT&CK gaps
rule backtest proves your rules fire correctly; rule coverage proves you have rules for the techniques you care about. It exports an ATT&CK Navigator layer and, with --fail-on-gaps, fails the build when a target technique list loses its last covering rule:
rsigma rule coverage -r rules/ --targets ci/threat-model.txt --fail-on-gaps
It can also surface techniques that have an Atomic Red Team test but no rule (--atomics) or that the SigmaHQ baseline covers but you do not (--baseline). See ATT&CK Coverage and the rule coverage reference.
rule scorecard for the metrics gate
rule backtest and rule coverage each emit a JSON report; rule scorecard fuses them (optionally enriched with a Prometheus production-volume snapshot and a triage feed) into per-rule keep/tune/retire verdicts and a gate. It is the metrics surface atop the triad: run it last, feeding the two reports the earlier steps wrote, and fail the build when the portfolio accrues retire-grade rules.
rsigma rule backtest -r rules/ --corpus ci/corpus/ --expectations ci/expectations.yml --report backtest.json
rsigma rule coverage -r rules/ --output-format json > coverage.json
rsigma rule scorecard --backtest backtest.json --coverage coverage.json --fail-on retire --report scorecard.md
A retire candidate that is the sole rule covering an ATT&CK technique is downgraded to tune, so the gate never asks you to drop coverage. See Detection Scorecard and the rule scorecard reference.
rule hygiene for the retirement cadence
rule scorecard needs the backtest and coverage reports; rule hygiene runs on the rules alone and flags retirement and clean-up candidates: untagged, unowned, undocumented (incomplete ADS), and deprecated/stale rules with no extra inputs, plus silence and noise when you add a Prometheus scrape and broken field coverage when you add a field-observability snapshot. Gate on the conditions your program treats as blocking:
rsigma rule hygiene -r rules/ --metrics metrics.txt \
--silent-threshold 365d --fail-on silent --fail-on no-owner
It exits 1 when a selected --fail-on condition matches, the same gating model as the rest of the triad. See Rule Hygiene and the rule hygiene reference.
--fail-on-detection for engine eval
engine eval --fail-on-detection is the zero-setup fallback when you do not want an expectations file: a single fixture and a single rule, where exit 1 means “something fired.”
rsigma engine eval -r rules/ --fail-on-detection -e @ci/should-not-match.ndjson
In a “should match” fixture, you actually want exit 1:
if rsigma engine eval -r rules/ --fail-on-detection -e @ci/should-match.ndjson; then
echo "ERROR: rule did not fire on the positive fixture"
exit 1
fi
Because this check is corpus-global, prefer rule backtest once a fixture exercises more than one rule. Pair --fail-on-detection with --no-detections if you only care whether correlations fire:
rsigma engine eval -r rules/ --fail-on-detection --no-detections \
--correlation-event-mode none < events.ndjson
--fail-level for rule lint
rule lint uses a tier system. The default threshold is error, meaning info, warning, and hint findings never fail the build:
rsigma rule lint rules/ # exit 1 only on error findings
rsigma rule lint rules/ --fail-level warning # exit 1 on warning or error
rsigma rule lint rules/ --fail-level info # exit 1 on info, warning, or error
For shared repositories, --fail-level warning strikes the right balance: spec violations break the build, missing-author or missing-description findings don’t. For SigmaHQ-style strict contributions, --fail-level info is reasonable.
rule doc --fail-on-missing for the ADS gate
To require every status: stable rule to carry a full detection-strategy document, add an ads: block to .rsigma-lint.yml and run rule doc as its own gate:
rsigma rule doc rules/ --fail-on-missing
It exits 1 when any enforced rule is missing a required ADS section, and 0 otherwise. The same sections also surface through rule lint (as ads_missing_* findings) if you prefer a single lint step; rule doc is the standalone view, and --missing-only narrows the report to just the rules below the bar.
The two gates agree on absent sections. They differ only on a section whose key is present but blank: rule doc --fail-on-missing treats it as undocumented and fails, while rule lint reports the lower-severity ads_empty_section (info) and so passes at the default --fail-level error. Run rule lint --fail-level info if you want the lint step to fail on blank sections too.
rule validate in CI
rule validate is the cheapest gate: it just parses and compiles every rule, no events involved. Wire it as the first step of any detection-as-code pipeline:
rsigma rule validate rules/
Exit 2 means a parse or compile error somewhere; the stdout summary shows the counts:
Parsed 0 documents from rules/
Detection rules: 0
Correlation rules: 0
Filter rules: 0
Parse errors: 1
Compiled OK: 0
Compile errors: 0
Add -p <pipeline.yml> to validate that your processing pipelines apply cleanly too:
rsigma rule validate rules/ -p pipelines/ecs.yml
For dynamic pipelines, add --resolve-sources so CI also exercises the HTTP/file/command sources at validation time. The job fails with exit 3 if any source is unreachable:
rsigma rule validate rules/ -p pipelines/dynamic.yml --resolve-sources
See Linting Rules and Processing Pipelines for the deeper context on each gate.
GitHub Actions
The fastest path on GitHub is the timescale/rsigma-action composite action, which runs the whole gate in one step. The manual multi-job workflow further down is the equivalent without a third-party action, and the pattern to copy for the other CI systems on this page.
Use the rsigma-action
name: detections
on:
pull_request:
permissions:
contents: read
pull-requests: write # the sticky summary comment
jobs:
detection-as-code:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0 # required for the merge-base fields-drift diff
- uses: timescale/rsigma-action@v1
with:
version: v0.18.0
rules: rules/
corpus: ci/corpus/
expectations: ci/expectations.yml
coverage: "true"
coverage-targets: ci/threat-model.txt
The action installs a checksum- and SLSA-attestation-verified rsigma release (cached per version and target, no insecure fallback), then runs rule lint (PR diff annotations generated from the stable --output-format json envelope, not text-scraping problem matchers), rule validate --resolve-sources, a merge-base fields-drift diff, rule backtest, and rule coverage, and keeps a single sticky summary comment up to date. It needs pull-requests: write for the comment and fetch-depth: 0 on checkout for the fields-drift diff. Pin a concrete version: so a silent rsigma upgrade cannot change CI behaviour between runs; the minimum supported version is the release where rule backtest and rule coverage shipped. See the action repository for the full input and output reference; hardened consumers can pin the action by commit SHA instead of the @v1 major tag.
Manual workflow
A four-job workflow that mirrors a typical detection-engineering loop: lint, validate, backtest, then convert and publish.
name: detections
on:
push:
branches: [main]
pull_request:
# Least-privilege default; jobs that need more (e.g. uploading artifacts) opt in.
permissions: {}
env:
RSIGMA_VERSION: "0.18.0"
jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- run: cargo install --locked rsigma --version "${RSIGMA_VERSION}"
- run: rsigma rule lint rules/ --fail-level warning
validate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- run: cargo install --locked rsigma --version "${RSIGMA_VERSION}"
- run: rsigma rule validate rules/ -p pipelines/ecs.yml
backtest:
runs-on: ubuntu-latest
needs: [lint, validate]
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- run: cargo install --locked rsigma --version "${RSIGMA_VERSION}"
- name: Backtest fixtures against expectations
run: |
rsigma rule backtest -r rules/ \
--corpus ci/corpus/ \
--expectations ci/expectations.yml \
--unexpected fail \
--junit backtest.xml
- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: backtest-junit
path: backtest.xml
convert:
runs-on: ubuntu-latest
needs: [validate]
if: github.ref == 'refs/heads/main'
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- run: cargo install --locked rsigma --version "${RSIGMA_VERSION}"
- run: rsigma backend convert rules/ -t postgres -f view -p pipelines/ecs.yml -o views.sql
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: postgres-views
path: views.sql
For a faster CI loop, install from the precompiled archives instead of cargo install. The release page publishes one archive per supported Rust target (see Installation for the full list):
- name: Install rsigma
run: |
curl -fsSL -o rsigma.tar.gz \
"https://github.com/timescale/rsigma/releases/download/v${RSIGMA_VERSION}/rsigma-x86_64-unknown-linux-gnu.tar.gz"
tar -xzf rsigma.tar.gz
sudo install -m 0755 rsigma /usr/local/bin/rsigma
rsigma --version
RSIGMA_VERSION is taken from the workflow-level env: shown above; pin it to a released tag so a silent rsigma upgrade cannot change CI behaviour between runs.
Detection-as-code repositories should hold themselves to the same supply-chain hygiene they expect from the rest of the org. Run zizmor against .github/workflows/ to catch missing permissions:, unpinned actions, script-injection-prone GitHub-context interpolations, and other GHA pitfalls. RSigma’s own workflows pass zizmor --pedantic with zero findings; the zizmor.yml workflow is a small reference to copy.
GitLab CI
stages: [check, eval, build]
variables:
RSIGMA_VERSION: "0.18.0"
default:
image: debian:bookworm-slim
.install-rsigma: &install-rsigma
before_script:
- apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
- curl -fsSL -o rsigma.tar.gz "https://github.com/timescale/rsigma/releases/download/v${RSIGMA_VERSION}/rsigma-x86_64-unknown-linux-gnu.tar.gz"
- tar -xzf rsigma.tar.gz
- install -m 0755 rsigma /usr/local/bin/rsigma
lint:
stage: check
<<: *install-rsigma
script:
- rsigma rule lint rules/ --fail-level warning
validate:
stage: check
<<: *install-rsigma
script:
- rsigma rule validate rules/ -p pipelines/ecs.yml
backtest:
stage: eval
<<: *install-rsigma
script:
- rsigma rule backtest -r rules/ --corpus ci/corpus/ --expectations ci/expectations.yml --unexpected fail --junit backtest.xml
artifacts:
when: always
reports:
junit: backtest.xml
convert-postgres:
stage: build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
<<: *install-rsigma
script:
- rsigma backend convert rules/ -t postgres -f view -p pipelines/ecs.yml -o views.sql
artifacts:
paths: [views.sql]
The only: [main] keyword is deprecated in modern GitLab CI; the rules: form above is the supported replacement and works on both gitlab.com and self-managed instances at 15.x+.
Pre-commit hook
A .pre-commit-config.yaml that runs the linter and validator on staged .yml rules:
repos:
- repo: local
hooks:
- id: rsigma-lint
name: rsigma rule lint
entry: rsigma rule lint rules/
language: system
pass_filenames: false
files: '^rules/.*\.ya?ml$'
- id: rsigma-validate
name: rsigma rule validate
entry: rsigma rule validate rules/ -p pipelines/ecs.yml
language: system
pass_filenames: false
files: '^(rules|pipelines)/.*\.ya?ml$'
rsigma rule lint and rsigma rule validate take a single <PATH> argument (a file or a directory), so pass_filenames: false is required on both hooks. The files: glob still scopes pre-commit’s trigger to the rule and pipeline directories, while the hook itself lints or validates the whole tree.
For auto-fix on commit, run --fix then check for diffs:
rsigma rule lint rules/ --fix
git diff --exit-code rules/
Exit code 1 from git diff --exit-code means an auto-fix changed a file that has not been committed yet; the commit hook should fail and ask the developer to re-stage.
Generic shell pipeline
For environments without a managed CI system (cron jobs, scheduled detection regression checks, Concourse, Drone, Argo Workflows):
#!/usr/bin/env bash
set -euo pipefail
RSIGMA_BIN="${RSIGMA_BIN:-rsigma}"
RULES_DIR="${RULES_DIR:-rules/}"
PIPELINE="${PIPELINE:-pipelines/ecs.yml}"
$RSIGMA_BIN rule lint "$RULES_DIR" --fail-level warning
$RSIGMA_BIN rule validate "$RULES_DIR" -p "$PIPELINE"
$RSIGMA_BIN rule backtest -r "$RULES_DIR" -p "$PIPELINE" \
--corpus ci/corpus/ \
--expectations ci/expectations.yml \
--unexpected fail
set -e plus set -o pipefail makes any non-zero exit fail the script, so the structured exit codes work without explicit if branches: a failed expectation or an unexpected fire returns exit 1 and stops the run.
Tips and gotchas
- Cache the rsigma binary between CI runs. The
cargo installform compiles rsigma from source and typically takes several minutes on a GitHub-hosted runner; the precompiled archive download completes in under 5 seconds. The release page ships archives forx86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,x86_64-apple-darwin,aarch64-apple-darwin,x86_64-pc-windows-msvc, andaarch64-pc-windows-msvc. - Pin the rsigma version in CI. Detection-as-code repos test specific behaviour; a silent rsigma upgrade can flip a previously-fixed bug. Use
cargo install --locked rsigma --version 0.18.0or pin the precompiled archive URL. - Separate lint and validate jobs. They fail for different reasons. A combined job hides which check broke.
- Avoid
set +earound rsigma. Structured exit codes are the API. Wrapping commands in|| trueorset +edefeats the whole model. - JSON output for diagnostic logs. Pass
--log-format jsonso CI log aggregators (Datadog CI Visibility, Buildkite test analytics) can parse run metadata without regex. Stdout/stderr are unchanged; only structured diagnostic logs flip to JSON. See Observability.
See also
- Evaluating Rules for the full
engine evalflag table and event extraction. - Linting Rules for the 85 lint rules, suppression, and
--fix. - Rule Conversion for the
backend convertworkflow that feedsviews.sqlinto Grafana or alerting. - Processing Pipelines for dynamic-source validation via
--resolve-sources. - Exit Codes reference for the canonical table and source-code link.
- CLI reference:
rule backtest,rule coverage,rule scorecard,engine eval,rule lint,rule validate,backend convert. - ATT&CK Coverage for the coverage workflow.
- Detection Scorecard for the keep/tune/retire verdict workflow.
timescale/rsigma-actionfor the one-step GitHub Actions gate.