pySigma-backend-sqlite 2.0.0__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pysigma_backend_sqlite-2.1.0/PKG-INFO +176 -0
- pysigma_backend_sqlite-2.1.0/README.md +157 -0
- {pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/pyproject.toml +3 -9
- {pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/sigma/backends/sqlite/correlation.py +21 -3
- {pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/sigma/backends/sqlite/runtime.py +71 -5
- {pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/sigma/backends/sqlite/sqlite.py +117 -51
- pysigma_backend_sqlite-2.0.0/PKG-INFO +0 -279
- pysigma_backend_sqlite-2.0.0/README.md +0 -259
- {pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/LICENSE +0 -0
- {pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/sigma/backends/sqlite/__init__.py +0 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pySigma-backend-sqlite
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: pySigma sqlite backend
|
|
5
|
+
License-Expression: LGPL-3.0-only
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: wagga
|
|
8
|
+
Requires-Python: >=3.11,<4.0
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
15
|
+
Requires-Dist: pysigma (>=2.0.0,<3.0)
|
|
16
|
+
Project-URL: Repository, https://github.com/SigmaHQ/pySigma-backend-sqlite
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
# pySigma SQLite Backend
|
|
23
|
+
|
|
24
|
+
Converts [Sigma](https://github.com/SigmaHQ/sigma) rules into SQLite queries. The
|
|
25
|
+
package `sigma.backends.sqlite` provides `sqliteBackend`, registered as the
|
|
26
|
+
sigma-cli target `sqlite`. It is the converter behind
|
|
27
|
+
[Zircolite](https://github.com/wagga40/Zircolite), and its output runs on any
|
|
28
|
+
SQLite table of flattened events.
|
|
29
|
+
|
|
30
|
+
Maintained by [wagga](https://github.com/wagga40/).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```shell
|
|
35
|
+
pip install pysigma-backend-sqlite
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.11+ and [pySigma](https://github.com/SigmaHQ/pySigma) 2.x.
|
|
39
|
+
Backend 2.0.0 is the last release for pySigma 1.5. Correlation output needs
|
|
40
|
+
SQLite 3.38 or newer.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from sigma.collection import SigmaCollection
|
|
46
|
+
from sigma.backends.sqlite import sqliteBackend
|
|
47
|
+
|
|
48
|
+
rules = SigmaCollection.from_yaml(r"""
|
|
49
|
+
title: Whoami execution
|
|
50
|
+
status: test
|
|
51
|
+
logsource:
|
|
52
|
+
category: process_creation
|
|
53
|
+
product: windows
|
|
54
|
+
detection:
|
|
55
|
+
selection:
|
|
56
|
+
Image|endswith: '\whoami.exe'
|
|
57
|
+
condition: selection
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
print(sqliteBackend().convert(rules)[0])
|
|
61
|
+
# SELECT * FROM logs WHERE Image LIKE '%\\whoami.exe' ESCAPE '\'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Processing pipelines map a logsource to concrete fields. With
|
|
65
|
+
`sqliteBackend(sysmon_pipeline() + windows_logsource_pipeline())` the same rule becomes:
|
|
66
|
+
|
|
67
|
+
```sql
|
|
68
|
+
SELECT * FROM logs WHERE Channel='Microsoft-Windows-Sysmon/Operational' AND (EventID=1 AND Image LIKE '%\\whoami.exe' ESCAPE '\')
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Those pipeline packages still declare `pysigma<2`; until they are updated, install
|
|
72
|
+
them with `pip install --no-deps pysigma-pipeline-sysmon pysigma-pipeline-windows`.
|
|
73
|
+
With sigma-cli:
|
|
74
|
+
|
|
75
|
+
```shell
|
|
76
|
+
sigma convert -t sqlite -f zircolite -p sysmon -p windows-logsources rules/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
sigma-cli 3.1 still requires pySigma 1.x, so it installs backend 2.0.0.
|
|
80
|
+
|
|
81
|
+
## Output formats
|
|
82
|
+
|
|
83
|
+
| Format | Output |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `default` | One `SELECT * FROM logs WHERE ...` statement per rule |
|
|
86
|
+
| `zircolite` | A JSON list with one entry per rule: Sigma metadata, the SQL, and what a consumer needs to run it. See [output format](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/output-format.md) |
|
|
87
|
+
|
|
88
|
+
## Options
|
|
89
|
+
|
|
90
|
+
Pass options as constructor keywords, or from sigma-cli with `-O name=value`.
|
|
91
|
+
Options are read at construction; assigning attributes later skips validation
|
|
92
|
+
and does not change the timestamp expression.
|
|
93
|
+
|
|
94
|
+
| Option | Default | Purpose |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `table` | `logs` | Table to query. A pipeline `setState` on `table` takes precedence |
|
|
97
|
+
| `collate_nocase` | `false` | Emit `field='value' COLLATE NOCASE` for tables without `NOCASE` columns |
|
|
98
|
+
| `max_flat_operands` | `100` | Longer AND/OR chains are nested to stay under SQLite's expression depth limit |
|
|
99
|
+
| `fulltext_field` | none | Column of searchable event text; enables [keyword search](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/keyword-search.md) |
|
|
100
|
+
| `timestamp_field` | `timestamp` | Event time column used by correlations |
|
|
101
|
+
| `timestamp_format` | `iso` | `iso`, `unix`, `unix_ms` or `unix_us` |
|
|
102
|
+
| `timestamp_seconds_expression` | ISO parser | SQL turning `{field}` into Unix seconds; overrides `timestamp_format` |
|
|
103
|
+
| `event_id_field` | `rowid` | Non-NULL, per-table unique event identifier for correlation evidence |
|
|
104
|
+
| `observation_end` | latest event | Unix seconds up to which absence windows can expire |
|
|
105
|
+
|
|
106
|
+
## Generated SQL
|
|
107
|
+
|
|
108
|
+
| Modifier | SQL |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `contains`, `startswith`, `endswith` | `LIKE '%value%' ESCAPE '\'` and its prefix/suffix forms |
|
|
111
|
+
| `all` | `AND` of the values |
|
|
112
|
+
| `re` | `REGEXP` (needs a registered function, see [Limitations](#limitations)) |
|
|
113
|
+
| `cidr` | `=` or `LIKE` prefixes of the expanded network |
|
|
114
|
+
| `cased` | `GLOB`; literal `*`, `?`, `[` become `[*]`, `[?]`, `[[]` |
|
|
115
|
+
| `fieldref` | `field1=field2`, or `LIKE` with the second field's `%` and `_` escaped |
|
|
116
|
+
| `exists` | `IS NOT NULL` / `IS NULL` |
|
|
117
|
+
| `gt`, `gte`, `lt`, `lte`, `neq` | `>`, `>=`, `<`, `<=`, negated equality |
|
|
118
|
+
| `minute`, `hour`, `day`, `week`, `month`, `year` | `strftime()` part |
|
|
119
|
+
| `base64`, `base64offset`, `wide`, `utf16*`, `windash` | Expanded by pySigma into ordinary matches |
|
|
120
|
+
|
|
121
|
+
The SQL keeps Sigma's meaning without help from the caller:
|
|
122
|
+
|
|
123
|
+
- **Negation.** Sigma reads a condition on a missing field as false; SQLite reads
|
|
124
|
+
it as `NULL`, and `NOT NULL` drops the row. Every `NOT` therefore wraps its
|
|
125
|
+
operand: `EventID=1 AND (NOT COALESCE((CommandLine LIKE '%foo%' ESCAPE '\'), 0))`.
|
|
126
|
+
- **Case.** `LIKE` ignores case. `=` does only on `NOCASE` columns, which is how
|
|
127
|
+
Zircolite builds its table; otherwise set `collate_nocase`. `cased` uses `GLOB`.
|
|
128
|
+
- **Booleans.** `Flag: true` matches text or integer storage: `(Flag='true' OR Flag=1)`.
|
|
129
|
+
- **Long lists.** Chains above `max_flat_operands` are regrouped, so rules with
|
|
130
|
+
thousands of values don't fail with "Expression tree is too large".
|
|
131
|
+
- **Field names.** SQLite keywords and names with characters other than letters,
|
|
132
|
+
digits and `_` are quoted with backticks. `field: null` becomes `field IS NULL`.
|
|
133
|
+
|
|
134
|
+
## Correlations
|
|
135
|
+
|
|
136
|
+
All Sigma correlation types run in SQLite: `event_count`, `value_count`,
|
|
137
|
+
`value_sum`, `value_avg`, `value_median`, `value_percentile`, `temporal` and
|
|
138
|
+
`temporal_ordered`, including chains, aliases and extended temporal conditions.
|
|
139
|
+
Each result row is one alert occurrence with its window, metric and contributing
|
|
140
|
+
event IDs. Semantics are described in [correlations](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/correlations.md);
|
|
141
|
+
upgrading from backend 1.x is covered in the [migration guide](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/migration-v2.md).
|
|
142
|
+
|
|
143
|
+
## Keyword search
|
|
144
|
+
|
|
145
|
+
Keyword (fieldless) selections need a column of searchable event text:
|
|
146
|
+
`sqliteBackend(fulltext_field="_sigma_fulltext")`. Without it, keyword rules raise
|
|
147
|
+
an error rather than guess. See [keyword search](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/keyword-search.md).
|
|
148
|
+
|
|
149
|
+
## Limitations
|
|
150
|
+
|
|
151
|
+
- **REGEXP.** SQLite has no built-in `REGEXP`. Register one that is false for `NULL`:
|
|
152
|
+
`connection.create_function("regexp", 2, lambda p, v: v is not None and re.search(p, str(v)) is not None)`.
|
|
153
|
+
pySigma 2 validates `|re` patterns with RE2, so rules with lookarounds or
|
|
154
|
+
backreferences are rejected when parsed.
|
|
155
|
+
- **CIDR.** SQLite has no network type. Prefix patterns match IPv6 addresses only
|
|
156
|
+
in the spellings pySigma expands to.
|
|
157
|
+
- **Case folding** (`NOCASE`, `LIKE`) is ASCII only.
|
|
158
|
+
- **Timestamp parts** use `strftime()` and need ISO-8601 text; a numeric epoch is
|
|
159
|
+
read as a Julian day. `%W` can return week 0.
|
|
160
|
+
- **Field existence.** SQL `NULL` cannot tell a missing field from an explicit null.
|
|
161
|
+
- **Multi-field `value_count`** (a list in `condition.field`) is rejected.
|
|
162
|
+
- **Unknown columns.** A query naming a column the table lacks fails to prepare.
|
|
163
|
+
Add missing columns as `NULL` first; see [output format](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/output-format.md).
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```shell
|
|
168
|
+
poetry install
|
|
169
|
+
poetry run pytest
|
|
170
|
+
SQLITE_MIN_BINARY=/path/to/sqlite-3.38 poetry run pytest tests/test_sqlite_minimum.py
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`tools/audit_corpus.py` converts and prepares a SigmaHQ checkout per profile
|
|
174
|
+
(coverage, not detection accuracy). `tools/benchmark_correlations.py` times
|
|
175
|
+
sparse and dense correlation windows.
|
|
176
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+

|
|
2
|
+

|
|
3
|
+
|
|
4
|
+
# pySigma SQLite Backend
|
|
5
|
+
|
|
6
|
+
Converts [Sigma](https://github.com/SigmaHQ/sigma) rules into SQLite queries. The
|
|
7
|
+
package `sigma.backends.sqlite` provides `sqliteBackend`, registered as the
|
|
8
|
+
sigma-cli target `sqlite`. It is the converter behind
|
|
9
|
+
[Zircolite](https://github.com/wagga40/Zircolite), and its output runs on any
|
|
10
|
+
SQLite table of flattened events.
|
|
11
|
+
|
|
12
|
+
Maintained by [wagga](https://github.com/wagga40/).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```shell
|
|
17
|
+
pip install pysigma-backend-sqlite
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python 3.11+ and [pySigma](https://github.com/SigmaHQ/pySigma) 2.x.
|
|
21
|
+
Backend 2.0.0 is the last release for pySigma 1.5. Correlation output needs
|
|
22
|
+
SQLite 3.38 or newer.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from sigma.collection import SigmaCollection
|
|
28
|
+
from sigma.backends.sqlite import sqliteBackend
|
|
29
|
+
|
|
30
|
+
rules = SigmaCollection.from_yaml(r"""
|
|
31
|
+
title: Whoami execution
|
|
32
|
+
status: test
|
|
33
|
+
logsource:
|
|
34
|
+
category: process_creation
|
|
35
|
+
product: windows
|
|
36
|
+
detection:
|
|
37
|
+
selection:
|
|
38
|
+
Image|endswith: '\whoami.exe'
|
|
39
|
+
condition: selection
|
|
40
|
+
""")
|
|
41
|
+
|
|
42
|
+
print(sqliteBackend().convert(rules)[0])
|
|
43
|
+
# SELECT * FROM logs WHERE Image LIKE '%\\whoami.exe' ESCAPE '\'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Processing pipelines map a logsource to concrete fields. With
|
|
47
|
+
`sqliteBackend(sysmon_pipeline() + windows_logsource_pipeline())` the same rule becomes:
|
|
48
|
+
|
|
49
|
+
```sql
|
|
50
|
+
SELECT * FROM logs WHERE Channel='Microsoft-Windows-Sysmon/Operational' AND (EventID=1 AND Image LIKE '%\\whoami.exe' ESCAPE '\')
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Those pipeline packages still declare `pysigma<2`; until they are updated, install
|
|
54
|
+
them with `pip install --no-deps pysigma-pipeline-sysmon pysigma-pipeline-windows`.
|
|
55
|
+
With sigma-cli:
|
|
56
|
+
|
|
57
|
+
```shell
|
|
58
|
+
sigma convert -t sqlite -f zircolite -p sysmon -p windows-logsources rules/
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
sigma-cli 3.1 still requires pySigma 1.x, so it installs backend 2.0.0.
|
|
62
|
+
|
|
63
|
+
## Output formats
|
|
64
|
+
|
|
65
|
+
| Format | Output |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `default` | One `SELECT * FROM logs WHERE ...` statement per rule |
|
|
68
|
+
| `zircolite` | A JSON list with one entry per rule: Sigma metadata, the SQL, and what a consumer needs to run it. See [output format](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/output-format.md) |
|
|
69
|
+
|
|
70
|
+
## Options
|
|
71
|
+
|
|
72
|
+
Pass options as constructor keywords, or from sigma-cli with `-O name=value`.
|
|
73
|
+
Options are read at construction; assigning attributes later skips validation
|
|
74
|
+
and does not change the timestamp expression.
|
|
75
|
+
|
|
76
|
+
| Option | Default | Purpose |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `table` | `logs` | Table to query. A pipeline `setState` on `table` takes precedence |
|
|
79
|
+
| `collate_nocase` | `false` | Emit `field='value' COLLATE NOCASE` for tables without `NOCASE` columns |
|
|
80
|
+
| `max_flat_operands` | `100` | Longer AND/OR chains are nested to stay under SQLite's expression depth limit |
|
|
81
|
+
| `fulltext_field` | none | Column of searchable event text; enables [keyword search](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/keyword-search.md) |
|
|
82
|
+
| `timestamp_field` | `timestamp` | Event time column used by correlations |
|
|
83
|
+
| `timestamp_format` | `iso` | `iso`, `unix`, `unix_ms` or `unix_us` |
|
|
84
|
+
| `timestamp_seconds_expression` | ISO parser | SQL turning `{field}` into Unix seconds; overrides `timestamp_format` |
|
|
85
|
+
| `event_id_field` | `rowid` | Non-NULL, per-table unique event identifier for correlation evidence |
|
|
86
|
+
| `observation_end` | latest event | Unix seconds up to which absence windows can expire |
|
|
87
|
+
|
|
88
|
+
## Generated SQL
|
|
89
|
+
|
|
90
|
+
| Modifier | SQL |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `contains`, `startswith`, `endswith` | `LIKE '%value%' ESCAPE '\'` and its prefix/suffix forms |
|
|
93
|
+
| `all` | `AND` of the values |
|
|
94
|
+
| `re` | `REGEXP` (needs a registered function, see [Limitations](#limitations)) |
|
|
95
|
+
| `cidr` | `=` or `LIKE` prefixes of the expanded network |
|
|
96
|
+
| `cased` | `GLOB`; literal `*`, `?`, `[` become `[*]`, `[?]`, `[[]` |
|
|
97
|
+
| `fieldref` | `field1=field2`, or `LIKE` with the second field's `%` and `_` escaped |
|
|
98
|
+
| `exists` | `IS NOT NULL` / `IS NULL` |
|
|
99
|
+
| `gt`, `gte`, `lt`, `lte`, `neq` | `>`, `>=`, `<`, `<=`, negated equality |
|
|
100
|
+
| `minute`, `hour`, `day`, `week`, `month`, `year` | `strftime()` part |
|
|
101
|
+
| `base64`, `base64offset`, `wide`, `utf16*`, `windash` | Expanded by pySigma into ordinary matches |
|
|
102
|
+
|
|
103
|
+
The SQL keeps Sigma's meaning without help from the caller:
|
|
104
|
+
|
|
105
|
+
- **Negation.** Sigma reads a condition on a missing field as false; SQLite reads
|
|
106
|
+
it as `NULL`, and `NOT NULL` drops the row. Every `NOT` therefore wraps its
|
|
107
|
+
operand: `EventID=1 AND (NOT COALESCE((CommandLine LIKE '%foo%' ESCAPE '\'), 0))`.
|
|
108
|
+
- **Case.** `LIKE` ignores case. `=` does only on `NOCASE` columns, which is how
|
|
109
|
+
Zircolite builds its table; otherwise set `collate_nocase`. `cased` uses `GLOB`.
|
|
110
|
+
- **Booleans.** `Flag: true` matches text or integer storage: `(Flag='true' OR Flag=1)`.
|
|
111
|
+
- **Long lists.** Chains above `max_flat_operands` are regrouped, so rules with
|
|
112
|
+
thousands of values don't fail with "Expression tree is too large".
|
|
113
|
+
- **Field names.** SQLite keywords and names with characters other than letters,
|
|
114
|
+
digits and `_` are quoted with backticks. `field: null` becomes `field IS NULL`.
|
|
115
|
+
|
|
116
|
+
## Correlations
|
|
117
|
+
|
|
118
|
+
All Sigma correlation types run in SQLite: `event_count`, `value_count`,
|
|
119
|
+
`value_sum`, `value_avg`, `value_median`, `value_percentile`, `temporal` and
|
|
120
|
+
`temporal_ordered`, including chains, aliases and extended temporal conditions.
|
|
121
|
+
Each result row is one alert occurrence with its window, metric and contributing
|
|
122
|
+
event IDs. Semantics are described in [correlations](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/correlations.md);
|
|
123
|
+
upgrading from backend 1.x is covered in the [migration guide](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/migration-v2.md).
|
|
124
|
+
|
|
125
|
+
## Keyword search
|
|
126
|
+
|
|
127
|
+
Keyword (fieldless) selections need a column of searchable event text:
|
|
128
|
+
`sqliteBackend(fulltext_field="_sigma_fulltext")`. Without it, keyword rules raise
|
|
129
|
+
an error rather than guess. See [keyword search](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/keyword-search.md).
|
|
130
|
+
|
|
131
|
+
## Limitations
|
|
132
|
+
|
|
133
|
+
- **REGEXP.** SQLite has no built-in `REGEXP`. Register one that is false for `NULL`:
|
|
134
|
+
`connection.create_function("regexp", 2, lambda p, v: v is not None and re.search(p, str(v)) is not None)`.
|
|
135
|
+
pySigma 2 validates `|re` patterns with RE2, so rules with lookarounds or
|
|
136
|
+
backreferences are rejected when parsed.
|
|
137
|
+
- **CIDR.** SQLite has no network type. Prefix patterns match IPv6 addresses only
|
|
138
|
+
in the spellings pySigma expands to.
|
|
139
|
+
- **Case folding** (`NOCASE`, `LIKE`) is ASCII only.
|
|
140
|
+
- **Timestamp parts** use `strftime()` and need ISO-8601 text; a numeric epoch is
|
|
141
|
+
read as a Julian day. `%W` can return week 0.
|
|
142
|
+
- **Field existence.** SQL `NULL` cannot tell a missing field from an explicit null.
|
|
143
|
+
- **Multi-field `value_count`** (a list in `condition.field`) is rejected.
|
|
144
|
+
- **Unknown columns.** A query naming a column the table lacks fails to prepare.
|
|
145
|
+
Add missing columns as `NULL` first; see [output format](https://github.com/wagga40/pySigma-backend-sqlite/blob/main/docs/output-format.md).
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```shell
|
|
150
|
+
poetry install
|
|
151
|
+
poetry run pytest
|
|
152
|
+
SQLITE_MIN_BINARY=/path/to/sqlite-3.38 poetry run pytest tests/test_sqlite_minimum.py
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`tools/audit_corpus.py` converts and prepares a SigmaHQ checkout per profile
|
|
156
|
+
(coverage, not detection accuracy). `tools/benchmark_correlations.py` times
|
|
157
|
+
sparse and dense correlation windows.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pySigma-backend-sqlite"
|
|
3
|
-
version = "2.
|
|
4
|
-
requires-python = ">=3.
|
|
5
|
-
dependencies = ["pysigma>=
|
|
3
|
+
version = "2.1.0"
|
|
4
|
+
requires-python = ">=3.11,<4.0"
|
|
5
|
+
dependencies = ["pysigma>=2.0.0,<3.0"]
|
|
6
6
|
description = "pySigma sqlite backend"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
authors = [
|
|
@@ -29,12 +29,6 @@ filterwarnings = [
|
|
|
29
29
|
"ignore:'parseString' deprecated:DeprecationWarning",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
|
-
[tool.pdm]
|
|
33
|
-
[tool.pdm.dev-dependencies]
|
|
34
|
-
dev = []
|
|
35
|
-
|
|
36
|
-
[tool.pdm.build]
|
|
37
|
-
includes = ["sigma"]
|
|
38
32
|
[build-system]
|
|
39
33
|
requires = ["poetry-core>=2.0.0"]
|
|
40
34
|
build-backend = "poetry.core.masonry.api"
|
{pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/sigma/backends/sqlite/correlation.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Relational correlation compiler shared by SQL and materialized execution.
|
|
2
2
|
|
|
3
|
-
Every stage is a SELECT. The portable form combines stages as CTEs;
|
|
4
|
-
|
|
3
|
+
Every stage is a SELECT. The portable form combines stages as CTEs;
|
|
4
|
+
runtime.execute_plan materializes the same stages, with explicit indexes. No
|
|
5
5
|
finalized query or output-format dictionary is ever used as a predicate.
|
|
6
6
|
"""
|
|
7
7
|
|
|
@@ -28,6 +28,7 @@ def literal(value: str) -> str:
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def quote(value: str) -> str:
|
|
31
|
+
# Backticks, not double quotes: SQLite reads an unknown "name" as a string literal.
|
|
31
32
|
return "`" + value.replace("`", "``") + "`"
|
|
32
33
|
|
|
33
34
|
|
|
@@ -114,6 +115,15 @@ class CorrelationCompiler:
|
|
|
114
115
|
self.source_tables = {}
|
|
115
116
|
self.diagnostic_queries = []
|
|
116
117
|
self._discover(root)
|
|
118
|
+
fulltext = {
|
|
119
|
+
(item["table"], item["field"])
|
|
120
|
+
for rule in self.base_rules.values()
|
|
121
|
+
for item in self.backend.fulltext_requirements(rule)
|
|
122
|
+
}
|
|
123
|
+
self.fulltext_requirements = [
|
|
124
|
+
{"table": table, "field": field, "representation": "source-values-v1"}
|
|
125
|
+
for table, field in sorted(fulltext)
|
|
126
|
+
]
|
|
117
127
|
self.field_ids = {f: f"f{i}" for i, f in enumerate(sorted(self.fields))}
|
|
118
128
|
# Stable identifiers make saved plans reproducible. Tables live in temp.
|
|
119
129
|
identity = json.dumps(root.to_dict(), sort_keys=True, default=str)
|
|
@@ -133,6 +143,10 @@ class CorrelationCompiler:
|
|
|
133
143
|
if isinstance(rule, SigmaCorrelationRule):
|
|
134
144
|
self.fields.update(rule.group_by or [])
|
|
135
145
|
field = getattr(rule.condition, "fieldref", None)
|
|
146
|
+
if isinstance(field, list):
|
|
147
|
+
raise SigmaFeatureNotSupportedByBackendError(
|
|
148
|
+
f"Correlation condition field list {field} is not supported"
|
|
149
|
+
)
|
|
136
150
|
if field:
|
|
137
151
|
self.fields.add(field)
|
|
138
152
|
for alias in rule.aliases:
|
|
@@ -181,6 +195,8 @@ class CorrelationCompiler:
|
|
|
181
195
|
self.required_by_table.setdefault(table, set()).update(
|
|
182
196
|
self.backend.required_fields(rule)
|
|
183
197
|
)
|
|
198
|
+
for item in self.fulltext_requirements:
|
|
199
|
+
self.required_by_table.setdefault(item["table"], set()).add(item["field"])
|
|
184
200
|
for i, (table, rules) in enumerate(by_table.items()):
|
|
185
201
|
self.source_tables[str(i)] = table
|
|
186
202
|
fields = self.required_by_table[table]
|
|
@@ -598,7 +614,7 @@ class CorrelationCompiler:
|
|
|
598
614
|
ctes = ", ".join(f"{s.name} AS ({s.select})" for s in self.stages)
|
|
599
615
|
diagnostics = " UNION ALL ".join(self.diagnostic_queries)
|
|
600
616
|
plan = {
|
|
601
|
-
"version": 2,
|
|
617
|
+
"version": 3 if self.fulltext_requirements else 2,
|
|
602
618
|
"sqlite_min_version": "3.38.0",
|
|
603
619
|
"prepare": [
|
|
604
620
|
dict(name=s.name, select=s.select, indexes=s.indexes)
|
|
@@ -613,4 +629,6 @@ class CorrelationCompiler:
|
|
|
613
629
|
"event_id_field": self.backend.event_id_field,
|
|
614
630
|
"source_tables": self.source_tables,
|
|
615
631
|
}
|
|
632
|
+
if self.fulltext_requirements:
|
|
633
|
+
plan["fulltext"] = self.fulltext_requirements
|
|
616
634
|
return "WITH " + ctes + " " + query, plan
|
{pysigma_backend_sqlite-2.0.0 → pysigma_backend_sqlite-2.1.0}/sigma/backends/sqlite/runtime.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Execute
|
|
1
|
+
"""Execute correlation plans on a consumer-owned SQLite connection."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -9,6 +9,57 @@ import sqlite3
|
|
|
9
9
|
from .correlation import quote
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
_TABLE_NAME = re.compile(
|
|
13
|
+
r"(?:[A-Za-z_][A-Za-z0-9_]*|`(?:[^`]|``)+`)(?:\.(?:[A-Za-z_][A-Za-z0-9_]*|`(?:[^`]|``)+`))*\Z"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_fulltext(connection, requirements):
|
|
18
|
+
"""Require a populated source-values-v1 TEXT column for every source row."""
|
|
19
|
+
if not isinstance(requirements, list) or not requirements:
|
|
20
|
+
raise ValueError("Keyword search requires nonempty fulltext requirements")
|
|
21
|
+
for item in requirements:
|
|
22
|
+
if not isinstance(item, dict) or set(item) != {
|
|
23
|
+
"table",
|
|
24
|
+
"field",
|
|
25
|
+
"representation",
|
|
26
|
+
}:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"Invalid fulltext requirement; re-ingest events for keyword search"
|
|
29
|
+
)
|
|
30
|
+
table, field = item["table"], item["field"]
|
|
31
|
+
if (
|
|
32
|
+
not isinstance(table, str)
|
|
33
|
+
or not _TABLE_NAME.fullmatch(table)
|
|
34
|
+
or not isinstance(field, str)
|
|
35
|
+
or not field
|
|
36
|
+
or item["representation"] != "source-values-v1"
|
|
37
|
+
):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"Invalid fulltext requirement; re-ingest events for keyword search"
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
cursor = connection.execute(f"SELECT * FROM {table} LIMIT 0")
|
|
43
|
+
existing = {column[0].lower() for column in cursor.description}
|
|
44
|
+
cursor.close()
|
|
45
|
+
if field.lower() not in existing:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Keyword text column {field!r} is missing from {table}; re-ingest events"
|
|
48
|
+
)
|
|
49
|
+
missing = connection.execute(
|
|
50
|
+
f"SELECT 1 FROM {table} WHERE {quote(field)} IS NULL "
|
|
51
|
+
f"OR typeof({quote(field)}) != 'text' LIMIT 1"
|
|
52
|
+
).fetchone()
|
|
53
|
+
except sqlite3.Error as error:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Keyword text is unavailable in {table}; re-ingest events"
|
|
56
|
+
) from error
|
|
57
|
+
if missing:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Keyword text in {table}.{field} is incomplete; re-ingest events"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
12
63
|
def ensure_fields(connection, requirements):
|
|
13
64
|
"""Materialize absent event fields as NULL, using compiler metadata."""
|
|
14
65
|
for table, fields in requirements.items():
|
|
@@ -23,15 +74,25 @@ def ensure_fields(connection, requirements):
|
|
|
23
74
|
existing.add(field.lower())
|
|
24
75
|
|
|
25
76
|
|
|
26
|
-
def execute_plan(
|
|
77
|
+
def execute_plan(
|
|
78
|
+
connection, plan, *, limit=None, include_events=True, fulltext_preflight=None
|
|
79
|
+
):
|
|
27
80
|
"""Return (alert summaries, diagnostic counts), cleaning temporary relations.
|
|
28
81
|
|
|
29
82
|
A limit truncates retrieval at limit+1 so the consumer can retain its
|
|
30
83
|
discard-noisy-rule policy. The executor never commits the caller's work.
|
|
31
|
-
Event IDs are scoped by source table;
|
|
84
|
+
Event IDs are scoped by source table; evidence carries each full source row.
|
|
85
|
+
A caller may supply a cache-aware fulltext_preflight(requirements) hook;
|
|
86
|
+
without one, version-3 plans validate on every execution.
|
|
32
87
|
"""
|
|
33
|
-
if plan.get("version")
|
|
88
|
+
if plan.get("version") not in (2, 3):
|
|
34
89
|
raise ValueError("Unsupported correlation plan version")
|
|
90
|
+
if plan["version"] == 3:
|
|
91
|
+
requirements = plan.get("fulltext")
|
|
92
|
+
if fulltext_preflight is None:
|
|
93
|
+
validate_fulltext(connection, requirements)
|
|
94
|
+
else:
|
|
95
|
+
fulltext_preflight(requirements)
|
|
35
96
|
if sqlite3.sqlite_version_info < (3, 38, 0):
|
|
36
97
|
raise RuntimeError(
|
|
37
98
|
"Correlation plans require SQLite >= 3.38 with JSON functions"
|
|
@@ -72,6 +133,9 @@ def execute_plan(connection, plan, *, limit=None, include_events=True):
|
|
|
72
133
|
for field in ("group_keys", "event_ids", "child_alert_ids"):
|
|
73
134
|
row[field] = json.loads(row[field])
|
|
74
135
|
if include_events:
|
|
136
|
+
internal_fields = {
|
|
137
|
+
item["field"].lower() for item in plan.get("fulltext", [])
|
|
138
|
+
}
|
|
75
139
|
evidence = {}
|
|
76
140
|
ids_by_table = {}
|
|
77
141
|
for row in rows:
|
|
@@ -96,7 +160,9 @@ def execute_plan(connection, plan, *, limit=None, include_events=True):
|
|
|
96
160
|
"event_id": identity,
|
|
97
161
|
"source_table": table,
|
|
98
162
|
"event": {
|
|
99
|
-
k: v
|
|
163
|
+
k: v
|
|
164
|
+
for k, v in zip(columns, event[1:])
|
|
165
|
+
if v is not None and k.lower() not in internal_fields
|
|
100
166
|
},
|
|
101
167
|
}
|
|
102
168
|
cursor.close()
|