Skip to content
Docs

    Run as code in CI

    5 min read · Updated View as Markdown

    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.yaml manifest. 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

    1. Create two CI keys

      In your workspace, under Settings › Tokens & API, create one CI key with security.read and another with security.manage.

    2. Store them in GitHub

      In the repository's settings, add the read key as the repository secret TRACIX_READ_KEY. Create an environment named tracix-production, add required reviewers, allow only the main branch, and add the apply key to it as the environment secret TRACIX_APPLY_KEY.

    3. Add the workspace address

      Add the repository variable TRACIX_URL with your workspace's address.

    4. Add the workflow

      Save the workflow below as .github/workflows/tracix-detections.yml and 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).

    .github/workflows/tracix-detections.yml
    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

    EventJobsKey
    A pull request from a branch of this repositoryvalidate, then plan, which uploads the plan as an artifactTRACIX_READ_KEY
    A push to mainplan, then apply once a required reviewer approves the tracix-production environmentTRACIX_READ_KEY to plan, TRACIX_APPLY_KEY to apply
    A manual run from mainplan, with replacements confirmed when you tick replace, then applySame 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:

    bash
    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.json
    bash
    node tracix-detections.mjs apply . plan.json

    Keep 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_URL over HTTPS, refuses redirects and masks the key in its output, but your CI logs still deserve the same care as the key.

    Next steps