sigmaforge 0.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.
- sigmaforge-0.1.0/LICENSE +21 -0
- sigmaforge-0.1.0/PKG-INFO +121 -0
- sigmaforge-0.1.0/README.md +106 -0
- sigmaforge-0.1.0/pyproject.toml +36 -0
- sigmaforge-0.1.0/setup.cfg +4 -0
- sigmaforge-0.1.0/sigmaforge/__init__.py +1 -0
- sigmaforge-0.1.0/sigmaforge/backtest/__init__.py +0 -0
- sigmaforge-0.1.0/sigmaforge/backtest/runner.py +21 -0
- sigmaforge-0.1.0/sigmaforge/banner.py +15 -0
- sigmaforge-0.1.0/sigmaforge/config.py +34 -0
- sigmaforge-0.1.0/sigmaforge/crosscheck/__init__.py +0 -0
- sigmaforge-0.1.0/sigmaforge/crosscheck/chainsaw.py +12 -0
- sigmaforge-0.1.0/sigmaforge/detect.py +42 -0
- sigmaforge-0.1.0/sigmaforge/ingest/__init__.py +0 -0
- sigmaforge-0.1.0/sigmaforge/ingest/chunker.py +22 -0
- sigmaforge-0.1.0/sigmaforge/ingest/ruleload.py +16 -0
- sigmaforge-0.1.0/sigmaforge/ingest/zircolite_runner.py +92 -0
- sigmaforge-0.1.0/sigmaforge/main.py +73 -0
- sigmaforge-0.1.0/sigmaforge/orchestrate.py +147 -0
- sigmaforge-0.1.0/sigmaforge/records.py +18 -0
- sigmaforge-0.1.0/sigmaforge/report/__init__.py +0 -0
- sigmaforge-0.1.0/sigmaforge/report/render.py +87 -0
- sigmaforge-0.1.0/sigmaforge/runmanifest.py +23 -0
- sigmaforge-0.1.0/sigmaforge/score/__init__.py +0 -0
- sigmaforge-0.1.0/sigmaforge/score/acceptance.py +96 -0
- sigmaforge-0.1.0/sigmaforge/score/adapter.py +19 -0
- sigmaforge-0.1.0/sigmaforge/score/coverage.py +28 -0
- sigmaforge-0.1.0/sigmaforge/score/gates.py +21 -0
- sigmaforge-0.1.0/sigmaforge/score/recall.py +123 -0
- sigmaforge-0.1.0/sigmaforge/score/scorer.py +23 -0
- sigmaforge-0.1.0/sigmaforge.egg-info/PKG-INFO +121 -0
- sigmaforge-0.1.0/sigmaforge.egg-info/SOURCES.txt +54 -0
- sigmaforge-0.1.0/sigmaforge.egg-info/dependency_links.txt +1 -0
- sigmaforge-0.1.0/sigmaforge.egg-info/entry_points.txt +2 -0
- sigmaforge-0.1.0/sigmaforge.egg-info/requires.txt +4 -0
- sigmaforge-0.1.0/sigmaforge.egg-info/top_level.txt +1 -0
- sigmaforge-0.1.0/tests/test_acceptance.py +60 -0
- sigmaforge-0.1.0/tests/test_attack_data_corpus.py +162 -0
- sigmaforge-0.1.0/tests/test_chunker.py +17 -0
- sigmaforge-0.1.0/tests/test_cli.py +27 -0
- sigmaforge-0.1.0/tests/test_coverage.py +21 -0
- sigmaforge-0.1.0/tests/test_crosscheck.py +13 -0
- sigmaforge-0.1.0/tests/test_detect.py +65 -0
- sigmaforge-0.1.0/tests/test_gates.py +27 -0
- sigmaforge-0.1.0/tests/test_golden.py +25 -0
- sigmaforge-0.1.0/tests/test_honesty_integration.py +20 -0
- sigmaforge-0.1.0/tests/test_manifest.py +26 -0
- sigmaforge-0.1.0/tests/test_optc_benign.py +240 -0
- sigmaforge-0.1.0/tests/test_orchestrate.py +238 -0
- sigmaforge-0.1.0/tests/test_recall.py +128 -0
- sigmaforge-0.1.0/tests/test_records.py +12 -0
- sigmaforge-0.1.0/tests/test_report.py +34 -0
- sigmaforge-0.1.0/tests/test_ruleload.py +15 -0
- sigmaforge-0.1.0/tests/test_runner.py +22 -0
- sigmaforge-0.1.0/tests/test_scorer.py +20 -0
- sigmaforge-0.1.0/tests/test_zircolite_runner.py +86 -0
sigmaforge-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christian Huhn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sigmaforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Honest Sigma-rule backtest harness
|
|
5
|
+
Author-email: Christian Huhn <duathron@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: typer>=0.12.0
|
|
11
|
+
Requires-Dist: rich>=13.7.0
|
|
12
|
+
Requires-Dist: pydantic>=2.7.0
|
|
13
|
+
Requires-Dist: shipwright-kit>=0.8.0
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# sigmaforge
|
|
17
|
+
|
|
18
|
+
**Honest Sigma-rule backtest harness.** Measures detection rules against real log
|
|
19
|
+
corpora and reports two numbers per rule — **recall** (does it catch attacks of its
|
|
20
|
+
ATT&CK sub-technique?) and **precision / false-positives** (does it fire on benign
|
|
21
|
+
activity?) — with honesty gates that return `unmeasured` instead of a fake `0` or a
|
|
22
|
+
tautological `1.0` when the data can't support a number.
|
|
23
|
+
|
|
24
|
+
[](https://github.com/duathron/sigmaforge/actions/workflows/ci.yml)
|
|
25
|
+

|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
> [!NOTE]
|
|
29
|
+
> **Learning / portfolio project, built by directing AI coding agents.** Christian
|
|
30
|
+
> Huhn (photography → SOC career change) designed, reviewed, and gated the work; the
|
|
31
|
+
> implementation was AI-pair-programmed. It is an honest measurement harness, not a
|
|
32
|
+
> polished product — see *Status* below for exactly what works and what doesn't yet.
|
|
33
|
+
|
|
34
|
+
## What problem it solves
|
|
35
|
+
|
|
36
|
+
Every SOC ships dozens to hundreds of detection rules and rarely measures them.
|
|
37
|
+
sigmaforge answers two questions with reproducible evidence:
|
|
38
|
+
|
|
39
|
+
- **Which rules are noise generators?** (high false-positives on legitimate activity)
|
|
40
|
+
- **Which rules catch nothing?** (zero recall against real attacks of their technique)
|
|
41
|
+
|
|
42
|
+
Example finding from a real run: *Suspicious Windows Service Tampering* produced 66
|
|
43
|
+
false-positives on a benign corpus — every one a Ninite / TeamViewer installer, not
|
|
44
|
+
an attack.
|
|
45
|
+
|
|
46
|
+
## How it actually works
|
|
47
|
+
|
|
48
|
+
```mermaid
|
|
49
|
+
flowchart LR
|
|
50
|
+
R[SigmaHQ rules] -->|partition high/critical| C[compile to one Zircolite ruleset]
|
|
51
|
+
C --> E[Zircolite engine]
|
|
52
|
+
A[(attack corpus<br/>sub-technique-labeled)] --> E
|
|
53
|
+
B[(benign corpus<br/>Nextron + OpTC)] --> E
|
|
54
|
+
E --> S[score: recall per technique<br/>+ precision/FP label-aware]
|
|
55
|
+
S --> G[honesty gates<br/>floor · positive-control · no-self-review]
|
|
56
|
+
G --> O[report.md + manifest]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The real pipeline is **script-driven** (`scripts/run6_backtest.py` is the current
|
|
60
|
+
end-to-end path):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv run python scripts/compile_loaded_ruleset.py # rules -> one Zircolite ruleset
|
|
64
|
+
uv run python scripts/run6_backtest.py # backtest -> reports/run6.md
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> [!WARNING]
|
|
68
|
+
> The shipped CLI (`sigmaforge backtest`) is a **weaker, work-in-progress path** and
|
|
69
|
+
> is not the way the real reports were produced. Use the scripts above. The CLI is
|
|
70
|
+
> kept for the future one-command experience, not parity.
|
|
71
|
+
|
|
72
|
+
## Status
|
|
73
|
+
|
|
74
|
+
| Area | State |
|
|
75
|
+
|------|-------|
|
|
76
|
+
| Recall (per sub-technique, no sibling dilution) | **Working** — 338/609 rules measurable, 70 fire (run5) |
|
|
77
|
+
| Precision / false-positives (label-aware, gated) | **Working** — 7/609 measurable on current benign corpus (run6) |
|
|
78
|
+
| Honesty gates (floor, positive-control, no-self-review) | **Working** |
|
|
79
|
+
| Reproducible manifest (run_hash, corpus SHAs, provenance) | **Working** |
|
|
80
|
+
| One-command CLI (`sigmaforge backtest`) | **WIP** — weaker than the scripts |
|
|
81
|
+
| Self-generated benign corpus | **Kit ready** (`scripts/selfgen/`), needs a Windows VM run |
|
|
82
|
+
|
|
83
|
+
> [!IMPORTANT]
|
|
84
|
+
> **The log corpora are not shipped.** They are large, separately licensed, and
|
|
85
|
+
> gitignored. `pip install sigmaforge` installs the harness code, not the data — a
|
|
86
|
+
> full end-to-end backtest needs the corpora and a local Zircolite checkout (also not
|
|
87
|
+
> bundled). The package is useful as a library / reference; the runnable pipeline
|
|
88
|
+
> needs the local setup documented in `scripts/`.
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install sigmaforge
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Installs the harness package and the `sigmaforge` CLI. The detection engine
|
|
97
|
+
([Zircolite](https://github.com/wagga40/Zircolite)) and the log corpora are obtained
|
|
98
|
+
separately (see above).
|
|
99
|
+
|
|
100
|
+
## Corpora used (all verified, portfolio-safe licenses)
|
|
101
|
+
|
|
102
|
+
| Corpus | Role | License |
|
|
103
|
+
|--------|------|---------|
|
|
104
|
+
| [splunk/attack_data](https://github.com/splunk/attack_data) | recall (sub-technique-labeled attacks) | Apache-2.0 |
|
|
105
|
+
| [DARPA OpTC](https://github.com/FiveDirections/OpTC-data) | precision (real enterprise benign week) | Public domain |
|
|
106
|
+
| [NextronSystems/evtx-baseline](https://github.com/NextronSystems/evtx-baseline) | precision (goodware baseline) | Apache-2.0 |
|
|
107
|
+
| Self-generated (`scripts/selfgen/`) | precision (targeted admin/LOLBin noise) | your own lab |
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
Built with the [Shipwright](https://github.com/duathron/shipwright) dev framework.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv sync --dev
|
|
115
|
+
uv run pytest # 108 tests
|
|
116
|
+
uv run ruff check .
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT © Christian Huhn. Corpus data retains its upstream license (see table above).
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# sigmaforge
|
|
2
|
+
|
|
3
|
+
**Honest Sigma-rule backtest harness.** Measures detection rules against real log
|
|
4
|
+
corpora and reports two numbers per rule — **recall** (does it catch attacks of its
|
|
5
|
+
ATT&CK sub-technique?) and **precision / false-positives** (does it fire on benign
|
|
6
|
+
activity?) — with honesty gates that return `unmeasured` instead of a fake `0` or a
|
|
7
|
+
tautological `1.0` when the data can't support a number.
|
|
8
|
+
|
|
9
|
+
[](https://github.com/duathron/sigmaforge/actions/workflows/ci.yml)
|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
> [!NOTE]
|
|
14
|
+
> **Learning / portfolio project, built by directing AI coding agents.** Christian
|
|
15
|
+
> Huhn (photography → SOC career change) designed, reviewed, and gated the work; the
|
|
16
|
+
> implementation was AI-pair-programmed. It is an honest measurement harness, not a
|
|
17
|
+
> polished product — see *Status* below for exactly what works and what doesn't yet.
|
|
18
|
+
|
|
19
|
+
## What problem it solves
|
|
20
|
+
|
|
21
|
+
Every SOC ships dozens to hundreds of detection rules and rarely measures them.
|
|
22
|
+
sigmaforge answers two questions with reproducible evidence:
|
|
23
|
+
|
|
24
|
+
- **Which rules are noise generators?** (high false-positives on legitimate activity)
|
|
25
|
+
- **Which rules catch nothing?** (zero recall against real attacks of their technique)
|
|
26
|
+
|
|
27
|
+
Example finding from a real run: *Suspicious Windows Service Tampering* produced 66
|
|
28
|
+
false-positives on a benign corpus — every one a Ninite / TeamViewer installer, not
|
|
29
|
+
an attack.
|
|
30
|
+
|
|
31
|
+
## How it actually works
|
|
32
|
+
|
|
33
|
+
```mermaid
|
|
34
|
+
flowchart LR
|
|
35
|
+
R[SigmaHQ rules] -->|partition high/critical| C[compile to one Zircolite ruleset]
|
|
36
|
+
C --> E[Zircolite engine]
|
|
37
|
+
A[(attack corpus<br/>sub-technique-labeled)] --> E
|
|
38
|
+
B[(benign corpus<br/>Nextron + OpTC)] --> E
|
|
39
|
+
E --> S[score: recall per technique<br/>+ precision/FP label-aware]
|
|
40
|
+
S --> G[honesty gates<br/>floor · positive-control · no-self-review]
|
|
41
|
+
G --> O[report.md + manifest]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The real pipeline is **script-driven** (`scripts/run6_backtest.py` is the current
|
|
45
|
+
end-to-end path):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv run python scripts/compile_loaded_ruleset.py # rules -> one Zircolite ruleset
|
|
49
|
+
uv run python scripts/run6_backtest.py # backtest -> reports/run6.md
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> [!WARNING]
|
|
53
|
+
> The shipped CLI (`sigmaforge backtest`) is a **weaker, work-in-progress path** and
|
|
54
|
+
> is not the way the real reports were produced. Use the scripts above. The CLI is
|
|
55
|
+
> kept for the future one-command experience, not parity.
|
|
56
|
+
|
|
57
|
+
## Status
|
|
58
|
+
|
|
59
|
+
| Area | State |
|
|
60
|
+
|------|-------|
|
|
61
|
+
| Recall (per sub-technique, no sibling dilution) | **Working** — 338/609 rules measurable, 70 fire (run5) |
|
|
62
|
+
| Precision / false-positives (label-aware, gated) | **Working** — 7/609 measurable on current benign corpus (run6) |
|
|
63
|
+
| Honesty gates (floor, positive-control, no-self-review) | **Working** |
|
|
64
|
+
| Reproducible manifest (run_hash, corpus SHAs, provenance) | **Working** |
|
|
65
|
+
| One-command CLI (`sigmaforge backtest`) | **WIP** — weaker than the scripts |
|
|
66
|
+
| Self-generated benign corpus | **Kit ready** (`scripts/selfgen/`), needs a Windows VM run |
|
|
67
|
+
|
|
68
|
+
> [!IMPORTANT]
|
|
69
|
+
> **The log corpora are not shipped.** They are large, separately licensed, and
|
|
70
|
+
> gitignored. `pip install sigmaforge` installs the harness code, not the data — a
|
|
71
|
+
> full end-to-end backtest needs the corpora and a local Zircolite checkout (also not
|
|
72
|
+
> bundled). The package is useful as a library / reference; the runnable pipeline
|
|
73
|
+
> needs the local setup documented in `scripts/`.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install sigmaforge
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Installs the harness package and the `sigmaforge` CLI. The detection engine
|
|
82
|
+
([Zircolite](https://github.com/wagga40/Zircolite)) and the log corpora are obtained
|
|
83
|
+
separately (see above).
|
|
84
|
+
|
|
85
|
+
## Corpora used (all verified, portfolio-safe licenses)
|
|
86
|
+
|
|
87
|
+
| Corpus | Role | License |
|
|
88
|
+
|--------|------|---------|
|
|
89
|
+
| [splunk/attack_data](https://github.com/splunk/attack_data) | recall (sub-technique-labeled attacks) | Apache-2.0 |
|
|
90
|
+
| [DARPA OpTC](https://github.com/FiveDirections/OpTC-data) | precision (real enterprise benign week) | Public domain |
|
|
91
|
+
| [NextronSystems/evtx-baseline](https://github.com/NextronSystems/evtx-baseline) | precision (goodware baseline) | Apache-2.0 |
|
|
92
|
+
| Self-generated (`scripts/selfgen/`) | precision (targeted admin/LOLBin noise) | your own lab |
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
Built with the [Shipwright](https://github.com/duathron/shipwright) dev framework.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv sync --dev
|
|
100
|
+
uv run pytest # 108 tests
|
|
101
|
+
uv run ruff check .
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT © Christian Huhn. Corpus data retains its upstream license (see table above).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sigmaforge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Honest Sigma-rule backtest harness"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Christian Huhn", email = "duathron@gmail.com" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"typer>=0.12.0",
|
|
15
|
+
"rich>=13.7.0",
|
|
16
|
+
"pydantic>=2.7.0",
|
|
17
|
+
"shipwright-kit>=0.8.0", # PyPI release (PyPI rejects git+ direct-URL deps)
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.shipwright]
|
|
21
|
+
preset = "security"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
sigmaforge = "sigmaforge.main:main"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = ["pytest>=8.0", "ruff>=0.8", "pre-commit>=4.0", "hypothesis>=6.0"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
include = ["sigmaforge*"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
extend = "tooling/ruff-base.toml"
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
2
|
+
|
|
3
|
+
from sigmaforge.ingest.chunker import chunk_lines
|
|
4
|
+
from sigmaforge.records import MatchRecord
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def aggregate(shard_results: list[list[MatchRecord]]) -> set[MatchRecord]:
|
|
8
|
+
merged: set[MatchRecord] = set()
|
|
9
|
+
for s in shard_results:
|
|
10
|
+
merged.update(s) # set union = order-independent, dedup across shard boundaries
|
|
11
|
+
return merged
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def backtest(items, shard_size, workers, shard_fn) -> set[MatchRecord]:
|
|
15
|
+
shards = list(chunk_lines(items, shard_size))
|
|
16
|
+
if workers == 1:
|
|
17
|
+
results = [shard_fn(s) for s in shards]
|
|
18
|
+
else:
|
|
19
|
+
with ProcessPoolExecutor(max_workers=workers) as ex:
|
|
20
|
+
results = list(ex.map(shard_fn, shards))
|
|
21
|
+
return aggregate(results)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""sigmaforge banner — uses the Shipwright design system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from shipwright_kit.design.banner import make_banner
|
|
8
|
+
|
|
9
|
+
from sigmaforge import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def show_banner(*, quiet: bool = False) -> None:
|
|
13
|
+
if quiet or not sys.stderr.isatty():
|
|
14
|
+
return
|
|
15
|
+
print(make_banner("sigmaforge", __version__, "Honest Sigma-rule backtest harness"), file=sys.stderr)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""sigmaforge configuration: ~/.sigmaforge/config.yaml + env > defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from shipwright_kit.config import app_dir, load_config
|
|
11
|
+
|
|
12
|
+
_APP_DIR = app_dir("sigmaforge")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutputConfig(BaseModel):
|
|
16
|
+
default_format: str = "rich"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AppConfig(BaseModel):
|
|
20
|
+
output: OutputConfig = OutputConfig()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_yaml(path: Path) -> dict:
|
|
24
|
+
with open(path) as f:
|
|
25
|
+
return yaml.safe_load(f) or {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(config_path: Optional[Path] = None) -> AppConfig:
|
|
29
|
+
"""Resolve config: explicit > ~/.sigmaforge/config.yaml > ./config.yaml > defaults."""
|
|
30
|
+
return load_config(
|
|
31
|
+
[config_path, _APP_DIR / "config.yaml", Path("config.yaml")],
|
|
32
|
+
loader=_load_yaml,
|
|
33
|
+
validator=AppConfig.model_validate,
|
|
34
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
def compare_loaded_intersection(z_hits, c_hits, z_loaded, c_loaded) -> dict:
|
|
2
|
+
"""A6 cross-engine integrity: compare Zircolite vs Chainsaw ONLY over rules BOTH engines
|
|
3
|
+
loaded. Rules only one engine loaded are a load artifact, reported separately — never as
|
|
4
|
+
detection disagreement. z_hits/c_hits: dict[rule -> set[event_id]]."""
|
|
5
|
+
both = z_loaded & c_loaded
|
|
6
|
+
agree = {r for r in both if z_hits.get(r, set()) == c_hits.get(r, set())}
|
|
7
|
+
return {
|
|
8
|
+
"compared_rules": both,
|
|
9
|
+
"agree": agree,
|
|
10
|
+
"disagree": both - agree,
|
|
11
|
+
"load_artifact_only": z_loaded ^ c_loaded, # symmetric difference = load artifact
|
|
12
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Example parse boundary — classify an untrusted input string.
|
|
2
|
+
|
|
3
|
+
Framework input-contract rule: external/garbage input never raises;
|
|
4
|
+
unrecognized input returns "unknown". Replace with your real logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_IPV4 = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
|
|
12
|
+
_HASH = re.compile(r"^[A-Fa-f0-9]{32,64}$")
|
|
13
|
+
_DOMAIN = re.compile(r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?:\.[A-Za-z]{2,})+$")
|
|
14
|
+
# File extensions that look like TLDs but are not valid domains for our purposes.
|
|
15
|
+
_FILE_EXTS = re.compile(
|
|
16
|
+
r"\.(dll|exe|so|dylib|sys|bin|bat|cmd|sh|ps1"
|
|
17
|
+
r"|py|js|ts|rb|go|rs|c|cpp|h|java|class|jar|war"
|
|
18
|
+
r"|zip|tar|gz|bz2|xz|7z|rar"
|
|
19
|
+
r"|pdf|doc|docx|xls|xlsx|ppt|pptx"
|
|
20
|
+
r"|png|jpg|jpeg|gif|svg|ico|mp3|mp4|avi|mov|mkv"
|
|
21
|
+
r"|log|txt|csv|json|xml|yaml|yml|toml|ini|cfg|conf|env)$",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify(value: object) -> str:
|
|
27
|
+
"""Return a coarse type for value. Never raises; unknown -> "unknown"."""
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
return "unknown"
|
|
30
|
+
v = value.strip()
|
|
31
|
+
if not v:
|
|
32
|
+
return "unknown"
|
|
33
|
+
if _IPV4.match(v):
|
|
34
|
+
parts = v.split(".")
|
|
35
|
+
if all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
|
36
|
+
return "ipv4"
|
|
37
|
+
return "unknown"
|
|
38
|
+
if _HASH.match(v):
|
|
39
|
+
return "hash"
|
|
40
|
+
if _DOMAIN.match(v) and not _FILE_EXTS.search(v):
|
|
41
|
+
return "domain"
|
|
42
|
+
return "unknown"
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Iterator, Sequence, TypeVar
|
|
2
|
+
|
|
3
|
+
T = TypeVar("T")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def chunk_lines(items: Sequence[T], shard_size: int) -> Iterator[list[T]]:
|
|
7
|
+
"""Partition items into chunks of shard_size.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
items: Sequence to partition
|
|
11
|
+
shard_size: Size of each chunk (must be >= 1)
|
|
12
|
+
|
|
13
|
+
Yields:
|
|
14
|
+
Lists of items, each of size shard_size (except possibly the last chunk)
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ValueError: If shard_size < 1
|
|
18
|
+
"""
|
|
19
|
+
if shard_size < 1:
|
|
20
|
+
raise ValueError("shard_size must be >= 1")
|
|
21
|
+
for i in range(0, len(items), shard_size):
|
|
22
|
+
yield list(items[i : i + shard_size])
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
def _is_stateful(rule: dict) -> bool:
|
|
2
|
+
if "correlation" in rule:
|
|
3
|
+
return True
|
|
4
|
+
cond = str(rule.get("detection", {}).get("condition", ""))
|
|
5
|
+
return any(tok in cond for tok in ("count(", "sum(", "avg(", "| near", "temporal"))
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def select_by_level(rules: list[dict], levels: tuple[str, ...]) -> list[dict]:
|
|
9
|
+
return [r for r in rules if str(r.get("level", "")).lower() in levels]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def partition_rules(rules: list[dict], levels: tuple[str, ...] = ("high", "critical")) -> tuple[list[dict], list[dict]]:
|
|
13
|
+
in_scope = select_by_level(rules, levels)
|
|
14
|
+
loaded = [r for r in in_scope if not _is_stateful(r)]
|
|
15
|
+
excluded = [r for r in in_scope if _is_stateful(r)]
|
|
16
|
+
return loaded, excluded
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
from sigmaforge.records import MatchRecord
|
|
7
|
+
|
|
8
|
+
ZIRCOLITE = ["uv", "run", "python", "Zircolite/zircolite.py"] # vendored 3.7.6
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _stable_event_id(row: dict) -> str:
|
|
12
|
+
"""Globally-unique event key (fix C). EventRecordID is a PER-EVTX-FILE counter, so using it
|
|
13
|
+
alone collapses record 42 of fileA with record 42 of fileB across a multi-file attack run and
|
|
14
|
+
silently deflates recall. Hash the whole flattened row instead: it carries Computer/UtcTime/
|
|
15
|
+
Image/CommandLine/... which differ across files even when EventRecordID repeats. Two genuinely
|
|
16
|
+
identical events still hash-collapse — that is correct dedup, not a collision bug.
|
|
17
|
+
(On real data each row also carries Zircolite's autoincrement `row_id`, globally unique
|
|
18
|
+
across a single multi-file run, so real events never over-split. NB: if `--parallel`
|
|
19
|
+
ingestion is ever enabled, `row_id` resets per chunk — uniqueness then rests on the
|
|
20
|
+
content fields, UtcTime/ProcessGuid/etc., which the whole-row hash already includes.)"""
|
|
21
|
+
canonical = json.dumps(row, sort_keys=True, default=str)
|
|
22
|
+
return hashlib.sha1(canonical.encode()).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_detections(
|
|
26
|
+
detections: list[dict],
|
|
27
|
+
corpus_label: str | None = None,
|
|
28
|
+
file_technique_map: dict[str, str] | None = None,
|
|
29
|
+
event_technique_out: dict[str, str] | None = None,
|
|
30
|
+
) -> list[MatchRecord]:
|
|
31
|
+
"""Parse Zircolite detections into MatchRecords.
|
|
32
|
+
|
|
33
|
+
FIX B: when ``file_technique_map`` (source-EVTX basename -> ATT&CK technique) and
|
|
34
|
+
``event_technique_out`` are supplied, also populate ``event_technique_out`` mapping
|
|
35
|
+
each fired event's ``event_id`` -> its ground-truth technique. The technique is keyed
|
|
36
|
+
on the SAME identity the engine emits (``_stable_event_id``), so a fire and its
|
|
37
|
+
technique join correctly downstream. The source file is read from each match row's
|
|
38
|
+
``OriginalLogfile`` (the EVTX basename, set by Zircolite's streaming flattener)."""
|
|
39
|
+
out: list[MatchRecord] = []
|
|
40
|
+
for d in detections:
|
|
41
|
+
for m in d.get("matches", []):
|
|
42
|
+
# benign COMISET rows carry the injected hash; native-EVTX rows do NOT -> derive a
|
|
43
|
+
# globally-unique key from the row (NOT bare EventRecordID, which collides across files).
|
|
44
|
+
eid = m.get("sigmaforge_eid") or _stable_event_id(m)
|
|
45
|
+
label = m.get("sigmaforge_label") or corpus_label or "benign"
|
|
46
|
+
out.append(MatchRecord(rule_id=d["title"], event_id=str(eid), event_label=label))
|
|
47
|
+
if file_technique_map is not None and event_technique_out is not None:
|
|
48
|
+
src = m.get("OriginalLogfile")
|
|
49
|
+
tech = file_technique_map.get(src) if src else None
|
|
50
|
+
if tech:
|
|
51
|
+
event_technique_out[str(eid)] = tech
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_shard(
|
|
56
|
+
events_path: str,
|
|
57
|
+
ruleset_glob: str,
|
|
58
|
+
mapping_path: str | None = None,
|
|
59
|
+
json_input: bool = True,
|
|
60
|
+
xml_input: bool = False,
|
|
61
|
+
corpus_label: str | None = None,
|
|
62
|
+
file_technique_map: dict[str, str] | None = None,
|
|
63
|
+
event_technique_out: dict[str, str] | None = None,
|
|
64
|
+
) -> list[MatchRecord]:
|
|
65
|
+
"""Run Zircolite over a shard and parse detections.
|
|
66
|
+
|
|
67
|
+
FIX B: pass ``file_technique_map`` + ``event_technique_out`` to also harvest the
|
|
68
|
+
per-event ground-truth technique (see ``parse_detections``).
|
|
69
|
+
|
|
70
|
+
FIX B3: ``xml_input=True`` ingests EVTX-converted-to-XML files (one wrapped
|
|
71
|
+
``<Events>...</Events>`` document per file, Zircolite ``--xml-input``). Like the
|
|
72
|
+
native-EVTX path, each event's ``OriginalLogfile`` is set to the .xml basename,
|
|
73
|
+
so the same ``file_technique_map`` (basename -> (sub-)technique) recall join
|
|
74
|
+
applies. ``json_input`` and ``xml_input`` are mutually exclusive."""
|
|
75
|
+
if json_input and xml_input:
|
|
76
|
+
raise ValueError("json_input and xml_input are mutually exclusive")
|
|
77
|
+
out = tempfile.NamedTemporaryFile(suffix=".json", delete=False).name
|
|
78
|
+
cmd = [*ZIRCOLITE, "--events", events_path, "--ruleset", ruleset_glob, "--outfile", out]
|
|
79
|
+
if json_input:
|
|
80
|
+
cmd += ["--json-input"]
|
|
81
|
+
if xml_input:
|
|
82
|
+
cmd += ["--xml-input"]
|
|
83
|
+
if mapping_path:
|
|
84
|
+
cmd += ["--config", mapping_path]
|
|
85
|
+
subprocess.run(cmd, check=True, cwd="/Users/christianhuhn/PycharmProjects/ai_project1/sigmaforge")
|
|
86
|
+
with open(out) as fh:
|
|
87
|
+
return parse_detections(
|
|
88
|
+
json.load(fh),
|
|
89
|
+
corpus_label=corpus_label,
|
|
90
|
+
file_technique_map=file_technique_map,
|
|
91
|
+
event_technique_out=event_technique_out,
|
|
92
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""sigmaforge — CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from shipwright_kit.cli import build_typer
|
|
7
|
+
|
|
8
|
+
from sigmaforge import __version__
|
|
9
|
+
from sigmaforge.detect import classify as classify_input
|
|
10
|
+
|
|
11
|
+
app = build_typer("sigmaforge", "Honest Sigma-rule backtest harness", version=__version__)
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def classify(value: str) -> None:
|
|
17
|
+
"""Classify an input string (example parse boundary)."""
|
|
18
|
+
console.print(classify_input(value))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def backtest(
|
|
23
|
+
rules: str,
|
|
24
|
+
attack: str,
|
|
25
|
+
benign: str,
|
|
26
|
+
out: str,
|
|
27
|
+
mapping: str = "data/mappings/comiset.yaml",
|
|
28
|
+
workers: int = 4,
|
|
29
|
+
min_events: int = 1000,
|
|
30
|
+
attack_events: int = 0,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Backtest Sigma rules: recall on the native-EVTX attack corpus, precision@COMISET on
|
|
33
|
+
the benign corpus. Writes the FP-tuning report to OUT. (Live end-to-end run; meaningful
|
|
34
|
+
precision numbers require the COMISET benign sample.)"""
|
|
35
|
+
import json
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import yaml
|
|
39
|
+
|
|
40
|
+
from sigmaforge.ingest.ruleload import partition_rules
|
|
41
|
+
from sigmaforge.ingest.zircolite_runner import run_shard
|
|
42
|
+
from sigmaforge.orchestrate import run_backtest
|
|
43
|
+
|
|
44
|
+
rule_docs = [
|
|
45
|
+
doc
|
|
46
|
+
for p in Path(rules).rglob("*.yml")
|
|
47
|
+
for doc in [yaml.safe_load(p.read_text())]
|
|
48
|
+
if isinstance(doc, dict) and doc.get("title")
|
|
49
|
+
]
|
|
50
|
+
loaded, _excluded = partition_rules(rule_docs)
|
|
51
|
+
benign_events = [json.loads(line) for line in Path(benign).read_text().splitlines()]
|
|
52
|
+
attack_fires = set(run_shard(attack, rules, json_input=False, corpus_label="malicious"))
|
|
53
|
+
benign_fires = set(run_shard(benign, rules, mapping_path=mapping, json_input=True))
|
|
54
|
+
pc_fired = any(f.event_label == "malicious" for f in benign_fires)
|
|
55
|
+
_rows, _funnel, md = run_backtest(
|
|
56
|
+
loaded,
|
|
57
|
+
attack_fires,
|
|
58
|
+
benign_fires,
|
|
59
|
+
benign_events,
|
|
60
|
+
n_attack_events=attack_events, # attack-corpus event count = recall denominator (provide via --attack-events)
|
|
61
|
+
positive_control_fired=pc_fired,
|
|
62
|
+
min_events=min_events,
|
|
63
|
+
)
|
|
64
|
+
Path(out).write_text(md)
|
|
65
|
+
console.print(f"report written: {out}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main() -> None:
|
|
69
|
+
app()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
main()
|