Skip to main content
Blog ·
·1048 words·5 mins· loading · loading

Deploying Detections Without Making a Mess

Idempotent deployment keyed on the Sigma UUID, so a renamed rule updates in place instead of spawning a duplicate. And a --dry-run that never lies about being deployed.

Taha
Author
Taha
A persistent, self-taught and serious learner.
Table of Contents
Series What The Sigma 7 parts
  1. 01 What The Sigma: Translating Detections Is Solved, Trusting Them Isn't
  2. 02 Phase 0: Interrogating QRadar's API Before You Trust It
  3. 03 Two Pipelines, One Choice: Converting Sigma to QRadar AQL
  4. 04 The ATT&CK v18 Renumbering Trap That Fakes Coverage Gaps
  5. 05 Deploying Detections Without Making a Mess You are here
  6. 06 Proving a Detection Actually Fires
  7. 07 An Honest Coverage Map (and a Console That Never Makes Up a Number)

The easy way to make a mess
#

The naive deployer is a for loop that POSTs each converted rule as a new saved search. Run it twice and you have two copies of every rule. When SigmaHQ renames a rule upstream, you get another copy under the new title while the old one continues firing. After a few weeks, the SIEM is full of near-identical detections, nobody knows which is authoritative and cleanup becomes manual archaeology.

The deployment phase of What The Sigma therefore has two non-negotiable properties: it is idempotent, so repeated runs converge instead of accumulating, and its status reporting reflects what reached QRadar.

Identity is the Sigma UUID
#

Every Sigma rule has a stable UUID that survives renames. That UUID is the identity used throughout the pipeline. Deployed saved searches embed it in their names so they are easy to find and distinguish:

output
[DaC] Renamed Whoami Execution [f1086bf7]
      \--- readable title ---/ \- UUID -/
python
def saved_search_name(prefix, title, sigma_id):
    tag = sigma_id.replace("-", "")[:UUID_TAG_LENGTH]   # first 8 hex chars
    budget = 255 - len(prefix) - len(tag) - 6           # keep long titles legal
    return f"{prefix} {title[:budget].strip()} [{tag}]"

The [DaC] prefix (detection-as-code) does double duty: it namespaces everything this pipeline creates, so the whole set can be listed - or deleted - by name without touching anything a human made by hand. The [f1086bf7] tag is the actual identity. The deployer matches on the tag rather than the title, so when a rule is renamed upstream it’s still recognised as the same detection and updated in place instead of duplicated. There’s a test dedicated to exactly this, because filling a SIEM with copies of one rule is an easy mistake to make.

Three honest outcomes
#

The deployer fetches the whole [DaC] namespace once, caches it by UUID tag, then decides per rule. There are three possible outcomes, and the third one is the one I built this around:

python
existing = _find_existing(client, name, cache)
if existing:
    if existing.get("aql") == payload["aql"]:
        record.status = "unchanged"          # byte-for-byte identical: no API call at all
    else:
        client.update_saved_search(int(existing["id"]), payload)
        record.status = "updated"            # the AQL changed: update in place
else:
    client.create_saved_search(payload)
    record.status = "created"                # genuinely new
  • created - the rule wasn’t there; make it.
  • updated - it was there but the AQL changed; update the existing object.
  • unchanged - the AQL is byte-for-byte identical, so no API call is made at all.

That last one keeps redeployment cheap and quiet. On a QRadar CE box, hundreds of redundant round-trips per run isn’t free, so a no-op run should actually do nothing. Fetching the namespace once, up front, also avoids a filtered GET per rule:

python
def _build_cache(client, prefix):
    cache = {}
    for item in client.list_saved_searches(prefix):
        name = item.get("name", "")
        if "[" in name and name.endswith("]"):
            cache[name.rsplit("[", 1)[-1].rstrip("]")] = item
    return cache

Why saved searches, not CRE rules
#

As covered in the Phase 0 post, the deployable unit is the Ariel saved search, not the Custom Rule Engine rule. The REST API creates saved searches directly, while /analytics/rules looks built for managing rules that already exist rather than authoring them from scratch. A saved search holds the converted AQL, which is exactly what a “scheduled search + threshold” CRE rule consumes anyway.

That’s still a working assumption until the live probe confirms it - so the code keeps it contained. The entire QRadar-shaped surface is one payload builder plus four client methods:

python
def build_payload(rule, name):
    return {
        "name": name,
        "description": description[:1000],   # ATT&CK, severity, pipeline, source
        "aql": rule["aql"],
        "database": "EVENTS",
        "is_shared": True,
    }

If Phase 0 comes back and says “no saved-search creation on this build”, switching to one of the fallbacks is a small, localised change rather than a rewrite. The deployer never touches Sigma and never touches pySigma - it just reads the manifest and talks to QRadar.

The status that refuses to lie: --dry-run
#

This is the discipline that matters most in this phase. --dry-run builds every payload and writes the full state file without calling QRadar:

python
if dry_run or client is None:
    records.append(record)   # status stays "dry-run"
    continue

Those records are stamped dry-run, and the coverage report excludes them from counting as deployed. Only a real API write counts:

python
# from src/report_navigator.py
live_states = {"created", "updated", "unchanged"}
for deployment in deployments.get("deployments", []):
    raw = deployment.get("status")
    if raw == "failed":
        status = "failed"
    elif raw in live_states:
        status = "deployed"
    else:
        continue   # dry-run records carry no coverage

Rehearsing a payload proves the payload builds. It does not prove QRadar received anything. If a dry run painted the ATT&CK matrix orange, I’d have a coverage map claiming detections on a machine that has never spoken to a SIEM, which is precisely the false confidence this whole project exists to kill. So a dry run stays loud about being a rehearsal, and the map stays honest.

This is not hypothetical. The lab SIEM is not up yet, so every deployment run has been a dry run. The pipeline is rehearsed, and the coverage map correctly shows nothing as deployed because nothing is. make deploy-dry is the command for a fresh clone, and its output reflects a machine without a SIEM.

State is a file, so it survives
#

Deployment writes state/deployments.json - what’s live, its saved-search id, when it was pushed, and the outcome per rule. That file is the handoff to validation (does the rule actually fire?) and to reporting (what colour is this technique?). Because state lives on disk rather than in memory, a deployment run can fail halfway through - the lab falling over, say - and I still haven’t lost the record of what did get pushed. Every phase in this pipeline reads one file and writes another; this is the one that says what QRadar currently believes.

Cleanup is free
#

One underrated benefit of the [DaC] prefix is teardown. Everything the pipeline ever created is listable and deletable by that one prefix, so resetting a lab - or removing the pipeline’s footprint entirely - never risks touching a hand-built rule. On a shared or borrowed QRadar instance, being able to say “delete exactly what I made and nothing else” is worth the six characters it costs.

Next, the phase this whole series is really about: proving a deployed detection actually fires.

Next in this series · What The Sigma 06 Proving a Detection Actually Fires