Run as code in CI
A pipeline for detection content follows one pattern: every pull request is validated and planned with a read-only key, and main applies the reviewed plan with a key that only a protected environment holds. A required reviewer approves the exact plan that ships, and the workspace refuses to apply anything that changed since.
Detection content only, for now
API keys can publish detection rules, value lists and YARA rulesets. Monitor and dashboard bundles need a workspace admin and can't be applied from CI yet. See Publish a bundle.
Before you begin
- A detection repository with a
tracix-detections.yamlmanifest. See Detection repositories. - A workspace admin to create two CI keys. See Create an API key.
- Your workspace's address, such as
https://production-a7k3p2q9.app.tracix.io.
Set up GitHub Actions
Create two CI keys
In your workspace, under Settings › Tokens & API, create one CI key with
security.readand another withsecurity.manage.Store them in GitHub
In the repository's settings, add the read key as the repository secret
TRACIX_READ_KEY. Create an environment namedtracix-production, add required reviewers, allow only themainbranch, and add the apply key to it as the environment secretTRACIX_APPLY_KEY.Add the workspace address
Add the repository variable
TRACIX_URLwith your workspace's address.Add the workflow
Save the workflow below as
.github/workflows/tracix-detections.ymland push it on a pull request.
Two repository variables are optional: TRACIX_CLI_SHA256 pins the CLI to a build you reviewed, using the digest the first run prints, and TRACIX_REPO_CREDENTIAL turns on archive mode for large repositories (see below).
name: Tracix detections
on:
pull_request:
paths:
- 'tracix-detections.yaml'
- 'tracix-detections.yml'
- 'sigma/**'
- 'native/**'
- 'lists/**'
- 'yara/**'
- 'tests/**'
- '.github/workflows/tracix-detections.yml'
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches: [main]
paths:
- 'tracix-detections.yaml'
- 'tracix-detections.yml'
- 'sigma/**'
- 'native/**'
- 'lists/**'
- 'yara/**'
- 'tests/**'
- '.github/workflows/tracix-detections.yml'
workflow_dispatch:
inputs:
replace:
description: Confirm replacing UI or pack rules that share a rule id with this repository
type: boolean
default: false
permissions:
contents: read
concurrency:
group: tracix-detections-${{ github.ref }}
# Pull requests: the newest push wins. main: applies queue, never cancel.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
TRACIX_URL: ${{ vars.TRACIX_URL }}
TRACIX_REPO_CREDENTIAL: ${{ vars.TRACIX_REPO_CREDENTIAL }}
jobs:
validate:
# Pull requests from forks get no secrets, so they are not validated here.
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
TRACIX_API_KEY: ${{ secrets.TRACIX_READ_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Fetch the CLI from the deployment
env:
TRACIX_CLI_SHA256: ${{ vars.TRACIX_CLI_SHA256 }}
run: |
set -euo pipefail
headers="$RUNNER_TEMP/cli.headers"
curl -fsS --proto '=https' --max-time 60 -H "x-api-key: $TRACIX_API_KEY" -D "$headers" -o "$RUNNER_TEMP/tracix-detections.mjs" "$TRACIX_URL/api/security/detections/cli"
served=$(grep -i '^x-tracix-cli-sha256:' "$headers" | awk '{print $2}' | tr -d '\r' || true)
expected="${TRACIX_CLI_SHA256:-$served}"
if [ -z "$expected" ]; then echo "::error::$TRACIX_URL did not serve the CLI (check TRACIX_URL and the key)"; exit 1; fi
echo "$expected $RUNNER_TEMP/tracix-detections.mjs" | sha256sum -c -
echo "tracix-detections CLI sha256 $expected"
- name: Validate (compile every file, run the fixtures)
run: node "$RUNNER_TEMP/tracix-detections.mjs" validate .
plan:
if: >-
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name != 'pull_request' && github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
permissions:
contents: read
# push to main: read the labels of the pull request that was merged.
pull-requests: read
env:
TRACIX_API_KEY: ${{ secrets.TRACIX_READ_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Fetch the CLI from the deployment
env:
TRACIX_CLI_SHA256: ${{ vars.TRACIX_CLI_SHA256 }}
run: |
set -euo pipefail
headers="$RUNNER_TEMP/cli.headers"
curl -fsS --proto '=https' --max-time 60 -H "x-api-key: $TRACIX_API_KEY" -D "$headers" -o "$RUNNER_TEMP/tracix-detections.mjs" "$TRACIX_URL/api/security/detections/cli"
served=$(grep -i '^x-tracix-cli-sha256:' "$headers" | awk '{print $2}' | tr -d '\r' || true)
expected="${TRACIX_CLI_SHA256:-$served}"
if [ -z "$expected" ]; then echo "::error::$TRACIX_URL did not serve the CLI (check TRACIX_URL and the key)"; exit 1; fi
echo "$expected $RUNNER_TEMP/tracix-detections.mjs" | sha256sum -c -
# The label confirmed the replacement on the pull request; the run on
# main after the merge carries it over from the merged pull request.
- name: Read the merged pull request's tracix-replace label
id: merged
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
replace=$(gh api "repos/$REPO/commits/$SHA/pulls" | jq -r --arg branch "$BRANCH" \
'[.[] | select(.merged_at != null and .base.ref == $branch) | .labels[].name] | index("tracix-replace") != null')
echo "replace=$replace" >> "$GITHUB_OUTPUT"
if [ "$replace" = "true" ]; then echo "The merged pull request carries tracix-replace: replacements are confirmed"; fi
- name: Plan (what apply would create, update, delete or replace)
env:
REPLACE: ${{ (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'tracix-replace')) || (github.event_name == 'workflow_dispatch' && inputs.replace) || steps.merged.outputs.replace == 'true' }}
run: |
set -euo pipefail
flags=()
if [ "$REPLACE" = "true" ]; then flags+=(--replace); fi
node "$RUNNER_TEMP/tracix-detections.mjs" plan . "$RUNNER_TEMP/tracix-plan.json" "${flags[@]}"
- name: Upload the plan
if: always()
uses: actions/upload-artifact@v4
with:
name: tracix-detections-plan
path: ${{ runner.temp }}/tracix-plan.json
if-no-files-found: ignore
retention-days: 30
apply:
needs: plan
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# Required reviewers on this environment approve the plan above (its
# table is in the plan job's summary) before anything is published.
environment: tracix-production
env:
TRACIX_API_KEY: ${{ secrets.TRACIX_APPLY_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Fetch the CLI from the deployment
env:
TRACIX_CLI_SHA256: ${{ vars.TRACIX_CLI_SHA256 }}
run: |
set -euo pipefail
headers="$RUNNER_TEMP/cli.headers"
curl -fsS --proto '=https' --max-time 60 -H "x-api-key: $TRACIX_API_KEY" -D "$headers" -o "$RUNNER_TEMP/tracix-detections.mjs" "$TRACIX_URL/api/security/detections/cli"
served=$(grep -i '^x-tracix-cli-sha256:' "$headers" | awk '{print $2}' | tr -d '\r' || true)
expected="${TRACIX_CLI_SHA256:-$served}"
if [ -z "$expected" ]; then echo "::error::$TRACIX_URL did not serve the CLI (check TRACIX_URL and the key)"; exit 1; fi
echo "$expected $RUNNER_TEMP/tracix-detections.mjs" | sha256sum -c -
- uses: actions/download-artifact@v4
with:
name: tracix-detections-plan
path: ${{ runner.temp }}/plan
# The server re-plans at this commit and refuses a plan that no longer
# matches (someone changed a rule since): re-run the workflow.
- name: Apply the reviewed plan
run: node "$RUNNER_TEMP/tracix-detections.mjs" apply . "$RUNNER_TEMP/plan/tracix-plan.json"What the workflow does
| Event | Jobs | Key |
|---|---|---|
| A pull request from a branch of this repository | validate, then plan, which uploads the plan as an artifact | TRACIX_READ_KEY |
A push to main | plan, then apply once a required reviewer approves the tracix-production environment | TRACIX_READ_KEY to plan, TRACIX_APPLY_KEY to apply |
A manual run from main | plan, with replacements confirmed when you tick replace, then apply | Same as a push |
- Each job downloads the CLI from your workspace and checks its SHA-256 against
TRACIX_CLI_SHA256, or against the digest the workspace serves when the variable isn't set. - On GitHub, the CLI writes annotations on the files that fail and a summary of the plan to the job.
- Pull requests from forks get no secrets, so the workflow skips them. Never give a pipeline that runs untrusted pull requests a key.
- On a pull request, a newer push cancels the older run. On
main, applies queue and are never cancelled.
Confirm a replacement
When a repository rule shares its rule ID with a rule created in the UI or installed from the Sigma pack, the plan fails with a conflict until you confirm the replacement. Add the tracix-replace label to the pull request. The run on main after the merge reads the label from the merged pull request, which is why its plan job has pull-requests: read. You can also run the workflow from main with replace ticked.
Use another CI system
The same steps work in any CI that runs Node.js 20 or later with curl and sha256sum. Run validate and plan on merge requests with the read key, and apply from your main branch with the apply key in a protected job:
curl -fsS --proto '=https' -H "X-API-Key: $TRACIX_API_KEY" \
-D cli.headers -o tracix-detections.mjs "$TRACIX_URL/api/security/detections/cli"
expected=$(grep -i '^x-tracix-cli-sha256:' cli.headers | awk '{print $2}' | tr -d '\r')
echo "$expected tracix-detections.mjs" | sha256sum -c -
node tracix-detections.mjs validate .
node tracix-detections.mjs plan . plan.jsonnode tracix-detections.mjs apply . plan.jsonKeep plan.json from the planning job and pass it to the apply job, so the plan your reviewer read is the plan that's applied. The CLI records the commit it publishes from the Git HEAD of the folder; set TRACIX_REVISION or --revision when the job has no Git checkout.
Large repositories
The CLI keeps each request under 1,000,000 bytes by default (TRACIX_MAX_REQUEST_BYTES, at most 10,485,760). Over that, validate sends the repository in batches, and plan and apply ask your workspace to fetch the commit from GitHub or GitLab instead of sending the files. That needs:
- a GitHub or GitLab credential stored in your workspace, with its ID in
TRACIX_REPO_CREDENTIAL; - the credential shared with your team at the Viewer level, because an API key reads stored credentials the way a Viewer does;
- the commit pushed, since the workspace fetches it from the code host.
Pass --archive to always use this mode.
Keep keys safe
- Use a read key to validate and plan, and keep the apply key in a protected environment with required reviewers.
- Give keys an expiry, and rotate them by creating a new key, updating the secret and revoking the old key.
- The CLI sends the key only in a header to
TRACIX_URLover HTTPS, refuses redirects and masks the key in its output, but your CI logs still deserve the same care as the key.