How it works¶
sigma2sql turns a rule into a dialect-independent match tree, then renders that tree as SQL for one database. Everything specific to a database is in the rendering step.
flowchart LR
Y[rule files] --> L[loader<br/>rules, correlations,<br/>count aggregations]
L --> C[condition parser]
L --> D[search compiler<br/>modifiers, wildcards,<br/>base64, windash]
C --> E[match tree]
D --> E
P[profile<br/>fields, logsources,<br/>timestamp] --> R
E --> R[renderer<br/>SQLite · PostgreSQL · DuckDB · ClickHouse]
R --> Q[SQL]
R --> K[correlation SQL<br/>self-join windows]
Q --> H[hunt / test / check]
K --> H
The pipeline¶
- Load. YAML documents become
Rule,CorrelationorFilterobjects. A rule keeps its searches as written. A condition with| count() ...is split into a plain condition and aCorrelationattached to the rule, so legacy aggregations and correlation rules share one code path from here on. - Collect. When several files are read, filters are attached to the rules they apply to, and rules that only feed a correlation are set aside.
- Compile. The condition is parsed, and each search it uses is compiled into a tree of
match nodes. Searches the condition never mentions are not compiled. Modifiers are resolved
here:
windashbecomes alternatives,base64becomes the encoded text,neqbecomes aNot, andexpandfills in placeholders. A filter adds its own tree with anAnd. - Render. The tree is walked once with a
Target, which holds the dialect, the table, the field mapping and the profile's log source routes. The result is aWHEREclause. - Run.
convertprints the query.testandcheckrun it on an in-memory SQLite database, andhunton an in-memory DuckDB database.
The tree for a small rule, and the SQL it renders to:
from sigma2sql import Target, load_rule, to_where
rule = load_rule(r"""
title: cmd.exe not started by SYSTEM
detection:
selection:
Image|endswith: '\cmd.exe'
filter:
User: SYSTEM
condition: selection and not filter
""")
tree = rule.compile()
print(tree)
print(to_where(tree, Target()))
And(items=(StrMatch(field='Image', value=SigmaString(parts=(<Wildcard.MANY: '*'>, '\\cmd.exe')), cased=False), Not(item=StrMatch(field='User', value=SigmaString(parts=('SYSTEM',)), cased=False))))
("Image" LIKE '%\\cmd.exe' ESCAPE '\' AND NOT COALESCE("User" = 'SYSTEM' COLLATE NOCASE, FALSE))
Modules¶
All code is in src/sigma2sql/.
| Module | What it does |
|---|---|
rule.py |
Reads YAML into Rule, Correlation and Filter, and checks their structure. Parses timespans and the legacy count() syntax. |
collection.py |
Loads many files, keeps per-file errors, attaches filters, finds the rules a correlation refers to and decides which rules report on their own. |
condition.py |
Tokenizer and recursive descent parser for detection.condition: and, or, not, parentheses, 1 of, all of and them. |
detection.py |
Compiles searches into match nodes and handles every modifier, including windash variants, base64offset and placeholders. |
expr.py |
The match tree: StrMatch, NumMatch, NullMatch, Exists, Regex, Cidr, Compare, TimePart, FieldRef, And, Or, Not, and SigmaString with its wildcards. Knows nothing about SQL. |
sql.py |
Target, Route and Renderer: fields, quoting, literals and each match node for each dialect. |
correlation.py |
SQL for correlations and count() aggregations, built from the pieces in sql.py. |
profile.py |
Loads profiles and placeholder files and applies them to a Target. |
profiles/ |
The built-in profiles: sysmon, evtx, ecs-winlogbeat and ocsf. |
runner.py |
In-memory SQLite for test and check, with regexp, cidr_match and sigma_percentile. |
hunt.py |
In-memory DuckDB for hunt: reads logs, handles damaged EVTX files, runs each rule and ranks the hits. |
sarif.py |
SARIF 2.1.0 reports. |
cli.py |
Argument parsing, the order of defaults, profile and options, output and exit codes. |
errors.py |
RuleError and UnsupportedRule. |
How the dialects differ¶
The fragments below come from to_where. Those for fields and numbers use a JSON column named
data; the others use a plain column to stay readable. In the longer ones, … stands for the
field.
Reading a field¶
With --nested, the second column applies.
| Dialect | Top-level key | Nested path |
|---|---|---|
| SQLite | json_extract("data", '$."Image"') |
json_extract("data", '$."process"."executable"') |
| PostgreSQL | "data"->>'Image' |
"data"#>>'{"process","executable"}' |
| DuckDB | ("data"->>'$."Image"') |
("data"->>'$."process"."executable"') |
| ClickHouse | JSONExtract("data", 'Image', 'Nullable(String)') |
JSONExtract("data", 'process', 'executable', 'Nullable(String)') |
Without --nested a dotted name is one key, so a.b does not reach into a. When a profile
lists several candidates for a field, they are wrapped in COALESCE. The parentheses on DuckDB
are there for versions before 2.0, which parse a AND b->>c = d as (a AND b)->>c = d. hunt
works from DuckDB 1.3 on.
All four give NULL for a missing key. On ClickHouse this is why the type is Nullable(String),
and numbers come back as text.
Text¶
Sigma matches without regard to case unless cased is set. For Image: cmd.exe and
Image|endswith: '\cmd.exe':
| Dialect | Exact value | With a wildcard |
|---|---|---|
| SQLite | "Image" = 'cmd.exe' COLLATE NOCASE |
"Image" LIKE '%\\cmd.exe' ESCAPE '\' |
| PostgreSQL | LOWER("Image") = 'cmd.exe' |
"Image" ILIKE '%\\cmd.exe' ESCAPE '\' |
| DuckDB | LOWER("Image") = 'cmd.exe' |
"Image" ILIKE '%\\cmd.exe' ESCAPE '\' |
| ClickHouse | lowerUTF8("Image") = 'cmd.exe' |
"Image" ILIKE '%\\\\cmd.exe' |
- SQLite's
NOCASEandLIKEfold ASCII only, so non-ASCII letters are case-sensitive there. - A
casedvalue without wildcards is a plain=on all four. With wildcards, SQLite usesGLOB, as itsLIKEignores case, and the others useLIKE. - ClickHouse treats a backslash in a string literal as an escape, so literals have their
backslashes doubled. It has no
ESCAPEclause, and inLIKEthe backslash always escapes. A backslash in a pattern is therefore written four times. - ClickHouse's
lower()only folds ASCII, solowerUTF8is used.
Keywords¶
A keyword search has no field. It runs on --keyword-column if given, otherwise on the JSON
document as text: "data" on SQLite and CAST("data" AS TEXT) elsewhere.
That text is the stored JSON, so keywords see its escaping. In the document, the path
C:\Windows is stored as C:\\Windows, and the keyword C:\Windows does not match it. The
same applies to keys: a keyword can match a field name as well as a value.
test, check and hunt on .evtx files write each event with Python's json.dumps and
ensure_ascii=False, so non-ASCII characters stay as they are and a keyword such as café
matches. A lone surrogate has no UTF-8 form, so it is replaced with ?. hunt keeps the text
of a JSON log file, so a \u00e9 in that file stays an escape and the keyword does not match
it. A search on a field is not affected.
Numbers¶
In a plain column a number is compared as a number, "EventID" = 4624. JSON may hold 4624 or
"4624", so in a JSON column the comparison is on text and both match:
| Dialect | EventID: 4624 |
|---|---|
| SQLite | CAST(json_extract("data", '$."EventID"') AS TEXT) = '4624' |
| PostgreSQL | "data"->>'EventID' = '4624' |
| DuckDB | ("data"->>'$."EventID"') = '4624' |
| ClickHouse | JSONExtract("data", 'EventID', 'Nullable(String)') = '4624' |
This applies to numbers written as numbers in the rule. A value quoted in the rule, '4624',
is text. On SQLite json_extract keeps the JSON type, so such a value matches "4624" in an
event but not 4624. On the other three databases the field is read as text and both match.
In a plain column, comparisons with lt, lte, gt and gte are direct: "Port" < 1024. In
a JSON column they need a number from a field that may hold anything. Windows logs write - in
number and address fields all the time, so a value that is not a number must give NULL, not an
error and not 0:
| Dialect | Port|lt: 1024 |
|---|---|
| SQLite | CASE WHEN CAST(… AS TEXT) GLOB '*[0-9]*' AND CAST(… AS TEXT) NOT GLOB '*[^0-9.eE+-]*' AND … THEN CAST(… AS REAL) END < 1024 |
| PostgreSQL | CASE WHEN pg_input_is_valid((…)::text, 'numeric') THEN (…)::numeric END < 1024 |
| DuckDB | TRY_CAST(… AS DOUBLE) < 1024 |
| ClickHouse | if(match(…, '^[+-]?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$'), toFloat64OrNull(…), NULL) < 1024 |
SQLite's CAST alone turns text into 0, which would satisfy lt. GLOB has no repetition, so
eight more NOT GLOB tests, left out above, describe what a number cannot look like: two dots,
a second exponent, a misplaced sign, an exponent with no digit before or after it. ClickHouse's
cast reads e5 as 0 and 1e+ as 1, and takes nan and inf, which is why match comes
first. PostgreSQL would end the whole query on one bad cast, which is why
pg_input_is_valid is there and why cidr, comparisons and correlations need PostgreSQL 16 or
newer.
Regular expressions¶
| Dialect | Image|re|i: 'a.b' |
Engine |
|---|---|---|
| SQLite | "Image" REGEXP '(?i)a.b' |
Python re, through the regexp function |
| PostgreSQL | "Image" ~ '(?i)a.b' |
PostgreSQL's own |
| DuckDB | regexp_matches("Image", '(?i)a.b') |
RE2 |
| ClickHouse | match("Image", '(?i-s)a.b') |
RE2 |
Flags that are written at the start of a pattern are merged with those from modifiers. Each engine has its own limits, and a pattern over a limit is reported as unsupported, not changed:
- RE2 has no lookaround or backreferences.
- Repetition counts such as
{300}may be at most 255 on PostgreSQL and 1000 on DuckDB and ClickHouse. SQLite has no such limit.
The flags need translating in two places:
- PostgreSQL's letters for
mandsdiffer from Python's:mbecomesn, andmwithsbecomesw. Without either flag,.matches a newline on PostgreSQL. - ClickHouse's
matchlets.reach newlines by default and Sigma's regexes do not, so(?-s)is added unless the rule asks fors.
CIDR¶
Addresses are parsed, never compared as text, so 0:0:0:0:0:0:0:1 is inside ::1/128. See
How it is checked for a case where this matters.
| Dialect | Ip|cidr: '10.0.0.0/8' |
|---|---|
| SQLite | cidr_match("Ip", '10.0.0.0/8') |
| PostgreSQL | CASE WHEN pg_input_is_valid(("Ip")::text, 'inet') THEN ("Ip")::inet END <<= '10.0.0.0/8'::inet |
| DuckDB | cidr_match("Ip", '10.0.0.0/8') |
| ClickHouse | ((isIPv4String(toString("Ip")) OR isIPv6String(toString("Ip"))) AND isIPAddressInRange(toString("Ip"), '10.0.0.0/8')) |
cidr_match is a Python function on SQLite and DuckDB. hunt, test and check register it;
see Library use for running the SQL elsewhere. On
ClickHouse the guard is there because isIPAddressInRange raises an error on text that is not
an address.
Time¶
Correlations and the modifiers minute, hour, day, week, month and year first turn
the field into seconds since 1970, according to the timestamp format:
| Dialect | ISO text to seconds |
|---|---|
| SQLite | unixepoch(…, 'subsec') |
| PostgreSQL | EXTRACT(EPOCH FROM CASE WHEN pg_input_is_valid((…)::text, 'timestamptz') THEN (…)::timestamptz END) |
| DuckDB | epoch(TRY_CAST(… AS TIMESTAMPTZ)) |
| ClickHouse | (toUnixTimestamp64Milli(parseDateTime64BestEffortOrNull(toString(…), 3, 'UTC')) / 1000.0) |
The parts are then read in UTC, and weeks are ISO 8601 weeks. On SQLite, sigma2sql finds the
Thursday of the event's week and counts from the start of that year. The subsec modifier
needs SQLite 3.42 or newer.
Other differences¶
- Null-safe equality. Correlation groups compare with
ISon SQLite andIS NOT DISTINCT FROMelsewhere, so events with a missing group field form a group. - Percentiles.
percentile_conton PostgreSQL,quantile_conton DuckDB,quantileExactInclusiveon ClickHouse and thesigma_percentileaggregate on SQLite. All four interpolate between the closest values. - Long lists.
AndandOrare grouped in fifties, because SQLite parsesa OR b OR ...into a tree as deep as the list and stops at depth 1000. On DuckDB, more than 50 case-insensitive patterns for one field become onelist_filteroverilike_escape, because DuckDB keeps memory perORterm and thread, and rules with thousands of patterns ran out of memory. - Result timestamps. On DuckDB window times are plain UTC
TIMESTAMPvalues, asTIMESTAMPTZvalues needpytzon the Python side.
Correlations¶
Every rule a correlation refers to adds rows to a base CTE: the rule's name, the event time,
the group values and the counted field. With a timespan, each event closes a window of its
group:
$ sigma2sql convert examples/rules/brute_force.yml
-- Brute force against one account (9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a505)
WITH base AS (
SELECT 'failed_logon' AS sigma_rule, unixepoch("timestamp", 'subsec') AS sigma_ts, "TargetUserName" AS "g0" FROM "events" WHERE "EventID" = 4625
),
windows AS (SELECT DISTINCT "g0", sigma_ts FROM base WHERE sigma_ts IS NOT NULL)
SELECT w."g0" AS "TargetUserName", datetime(MIN(b.sigma_ts), 'unixepoch', 'subsec') AS window_start, datetime(w.sigma_ts, 'unixepoch', 'subsec') AS window_end, COUNT(*) AS value
FROM windows w JOIN base b ON b."g0" IS w."g0" AND b.sigma_ts BETWEEN w.sigma_ts - 300 AND w.sigma_ts
GROUP BY w."g0", w.sigma_ts
HAVING COUNT(*) >= 3;
Each window that meets the condition gives a row, so a long burst gives one row for each
distinct event time from the point where the threshold is reached. Events without a usable time
do not close a window, see Event time. Without a timespan the
query is a plain GROUP BY over base, and the row has first_seen and last_seen in place
of the window's start and end. temporal_ordered takes neither path.
The aggregate depends on the correlation type:
| Type | Aggregate |
|---|---|
event_count |
COUNT(*) |
value_count |
COUNT(DISTINCT value) |
value_sum, value_avg |
SUM or AVG of the value as a number |
value_median, value_percentile |
the dialect's percentile function |
temporal |
COUNT(DISTINCT rule), by default at least the number of different entries in rules |
temporal_ordered |
none, see below |
temporal asks whether all the rules fired inside the window, in any order.
temporal_ordered is built by _ordered, with or without a timespan, and it uses neither
windows nor a HAVING. It has one CTE per step, each built on the one before it:
$ sigma2sql convert there_and_back.yml
-- Recon, a new service, recon again
WITH base AS (
SELECT 'recon' AS sigma_rule, unixepoch("timestamp", 'subsec') AS sigma_ts, "Host" AS "g0" FROM "events" WHERE "Image" LIKE '%\\whoami.exe' ESCAPE '\'
UNION ALL
SELECT 'new_service' AS sigma_rule, unixepoch("timestamp", 'subsec') AS sigma_ts, "Host" AS "g0" FROM "events" WHERE "EventID" = 7045
),
s0 AS (SELECT DISTINCT "g0", sigma_ts AS t0 FROM base WHERE sigma_rule = 'recon' AND sigma_ts IS NOT NULL),
s1 AS (SELECT p."g0" AS "g0", p.t0 AS t0, MIN(e.sigma_ts) AS t1 FROM s0 p JOIN base e ON e.sigma_rule = 'new_service' AND e."g0" IS p."g0" AND e.sigma_ts >= p.t0 AND e.sigma_ts <= p.t0 + 600 GROUP BY p."g0", p.t0),
s2 AS (SELECT p."g0" AS "g0", p.t0 AS t0, p.t1 AS t1, MIN(e.sigma_ts) AS t2 FROM s1 p JOIN base e ON e.sigma_rule = 'recon' AND e."g0" IS p."g0" AND e.sigma_ts >= p.t1 AND e.sigma_ts > p.t0 AND e.sigma_ts <= p.t0 + 600 GROUP BY p."g0", p.t0, p.t1)
SELECT "g0" AS "Host", datetime(t0, 'unixepoch', 'subsec') AS window_start, datetime(t2, 'unixepoch', 'subsec') AS window_end, 3 AS value
FROM s2;
s0 holds the start times of each group, without events that have no time, and DISTINCT
makes that one row per start time. Each later CTE compares a step with the one before it, using
>=, because logs often only have whole seconds. A rule that comes back also needs > against
its last earlier step, adjacent or not, as e.sigma_ts > p.t0 does in s2, or one event would
fill both. The upper limit is always p.t0 plus the timespan, and it is left out without one.
MIN with the GROUP BY keeps only the earliest event of the step, which can do no worse than
a later one, so each CTE has one row per start however dense the events are. The last SELECT
reads the last CTE: t0 gives window_start, the last time gives window_end, and value is
the number of steps. base has one branch per different rule, so recon is read once here
although it is listed twice.
Correlations shows what this means for the results.
Why it works this way¶
One tree, four renderers. Sigma's meaning is settled once, in detection.py and
condition.py. A new dialect only adds rendering, and the behaviour tests in tests/cases.py
and tests/test_correlation.py run unchanged on all four databases.
A self-join for windows. Not every database allows COUNT(DISTINCT ...) in a window
function; SQLite rejects it. The self-join allows it, and it works the same on every database.
NOT COALESCE(x, FALSE). A test on a missing field is NULL in SQL, and NOT NULL is NULL
again. Sigma treats the inner test as "no match", so its negation has to be true. This is why
not on a missing field matches.
No silent changes. A pattern a database cannot run is reported as unsupported. A rule with a placeholder you did not provide is skipped. Either is better than SQL that never matches or always matches without saying so.
A product filter in hunt. Without it, a Zeek rule of the form not <address range>
matches every Windows event, because those events have no such field. The Windows profiles
name their product for this reason.
DuckDB for hunt, SQLite for test. DuckDB reads JSON, Parquet and CSV directly and needs
no server, so a directory of logs can be searched with one command. test and check run in
CI on small files, and SQLite comes with Python, so they need no extra packages.
Keep what can be read. Logs from an incident are often damaged, so hunt reads what it can
of .evtx files and warns about the rest. It fails only when the whole hunt gives no event.
Other formats are read by DuckDB, and a file it cannot read ends the hunt.
Contributing and development¶
The project uses uv. Issues and pull requests go to brkozer/sigma2sql.
uv sync
uv run pytest # SQLite and DuckDB
SIGMA2SQL_PG_DSN=postgresql://postgres:test@localhost/postgres uv run pytest # + PostgreSQL
SIGMA2SQL_CH_URL=http://localhost:8123/ uv run pytest # + ClickHouse
CI also runs these, and a pull request should pass them:
The behaviour tests in tests/cases.py and tests/test_correlation.py run unchanged on all
four databases, so a new test of Sigma behaviour belongs there. The example rules and events in
examples/ were written for this project.
The checks against SigmaHQ need a checkout of its repository. They are described in How it is checked.
git clone --depth 1 https://github.com/SigmaHQ/sigma
uv run python scripts/check_sigmahq.py sigma/rules --dsn ... --clickhouse ... [--profile evtx]
uv run python scripts/check_regression.py sigma --clickhouse ... --evtx
uv run --with pysigma --with pysigma-backend-sqlite python scripts/diff_pysigma.py sigma
To rebuild the terminal image from real command output, and to build this site: