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.
Files changed (56) hide show
  1. sigmaforge-0.1.0/LICENSE +21 -0
  2. sigmaforge-0.1.0/PKG-INFO +121 -0
  3. sigmaforge-0.1.0/README.md +106 -0
  4. sigmaforge-0.1.0/pyproject.toml +36 -0
  5. sigmaforge-0.1.0/setup.cfg +4 -0
  6. sigmaforge-0.1.0/sigmaforge/__init__.py +1 -0
  7. sigmaforge-0.1.0/sigmaforge/backtest/__init__.py +0 -0
  8. sigmaforge-0.1.0/sigmaforge/backtest/runner.py +21 -0
  9. sigmaforge-0.1.0/sigmaforge/banner.py +15 -0
  10. sigmaforge-0.1.0/sigmaforge/config.py +34 -0
  11. sigmaforge-0.1.0/sigmaforge/crosscheck/__init__.py +0 -0
  12. sigmaforge-0.1.0/sigmaforge/crosscheck/chainsaw.py +12 -0
  13. sigmaforge-0.1.0/sigmaforge/detect.py +42 -0
  14. sigmaforge-0.1.0/sigmaforge/ingest/__init__.py +0 -0
  15. sigmaforge-0.1.0/sigmaforge/ingest/chunker.py +22 -0
  16. sigmaforge-0.1.0/sigmaforge/ingest/ruleload.py +16 -0
  17. sigmaforge-0.1.0/sigmaforge/ingest/zircolite_runner.py +92 -0
  18. sigmaforge-0.1.0/sigmaforge/main.py +73 -0
  19. sigmaforge-0.1.0/sigmaforge/orchestrate.py +147 -0
  20. sigmaforge-0.1.0/sigmaforge/records.py +18 -0
  21. sigmaforge-0.1.0/sigmaforge/report/__init__.py +0 -0
  22. sigmaforge-0.1.0/sigmaforge/report/render.py +87 -0
  23. sigmaforge-0.1.0/sigmaforge/runmanifest.py +23 -0
  24. sigmaforge-0.1.0/sigmaforge/score/__init__.py +0 -0
  25. sigmaforge-0.1.0/sigmaforge/score/acceptance.py +96 -0
  26. sigmaforge-0.1.0/sigmaforge/score/adapter.py +19 -0
  27. sigmaforge-0.1.0/sigmaforge/score/coverage.py +28 -0
  28. sigmaforge-0.1.0/sigmaforge/score/gates.py +21 -0
  29. sigmaforge-0.1.0/sigmaforge/score/recall.py +123 -0
  30. sigmaforge-0.1.0/sigmaforge/score/scorer.py +23 -0
  31. sigmaforge-0.1.0/sigmaforge.egg-info/PKG-INFO +121 -0
  32. sigmaforge-0.1.0/sigmaforge.egg-info/SOURCES.txt +54 -0
  33. sigmaforge-0.1.0/sigmaforge.egg-info/dependency_links.txt +1 -0
  34. sigmaforge-0.1.0/sigmaforge.egg-info/entry_points.txt +2 -0
  35. sigmaforge-0.1.0/sigmaforge.egg-info/requires.txt +4 -0
  36. sigmaforge-0.1.0/sigmaforge.egg-info/top_level.txt +1 -0
  37. sigmaforge-0.1.0/tests/test_acceptance.py +60 -0
  38. sigmaforge-0.1.0/tests/test_attack_data_corpus.py +162 -0
  39. sigmaforge-0.1.0/tests/test_chunker.py +17 -0
  40. sigmaforge-0.1.0/tests/test_cli.py +27 -0
  41. sigmaforge-0.1.0/tests/test_coverage.py +21 -0
  42. sigmaforge-0.1.0/tests/test_crosscheck.py +13 -0
  43. sigmaforge-0.1.0/tests/test_detect.py +65 -0
  44. sigmaforge-0.1.0/tests/test_gates.py +27 -0
  45. sigmaforge-0.1.0/tests/test_golden.py +25 -0
  46. sigmaforge-0.1.0/tests/test_honesty_integration.py +20 -0
  47. sigmaforge-0.1.0/tests/test_manifest.py +26 -0
  48. sigmaforge-0.1.0/tests/test_optc_benign.py +240 -0
  49. sigmaforge-0.1.0/tests/test_orchestrate.py +238 -0
  50. sigmaforge-0.1.0/tests/test_recall.py +128 -0
  51. sigmaforge-0.1.0/tests/test_records.py +12 -0
  52. sigmaforge-0.1.0/tests/test_report.py +34 -0
  53. sigmaforge-0.1.0/tests/test_ruleload.py +15 -0
  54. sigmaforge-0.1.0/tests/test_runner.py +22 -0
  55. sigmaforge-0.1.0/tests/test_scorer.py +20 -0
  56. sigmaforge-0.1.0/tests/test_zircolite_runner.py +86 -0
@@ -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
+ [![CI](https://github.com/duathron/sigmaforge/actions/workflows/ci.yml/badge.svg)](https://github.com/duathron/sigmaforge/actions/workflows/ci.yml)
25
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
26
+ ![license](https://img.shields.io/badge/license-MIT-green)
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
+ [![CI](https://github.com/duathron/sigmaforge/actions/workflows/ci.yml/badge.svg)](https://github.com/duathron/sigmaforge/actions/workflows/ci.yml)
10
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
11
+ ![license](https://img.shields.io/badge/license-MIT-green)
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()