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:
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:
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."
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:
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:
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:
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:
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.
Audit your detection workflow with zizmor
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:
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.