Your SIEM reports no alerts for
T1059.001this quarter.
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ 1. Nobody abused │ or │ 2. The rule cannot fire. │
│ PowerShell. │ │ │
│ │ │ It references a field your │
│ Good news. │ │ pipeline renamed two years │
│ Do nothing. │ │ ago. It has been dead ever │
│ │ │ since. Nobody noticed. │
└─────────────────────────────┘ └─────────────────────────────┘
Identical from a dashboard. Opposite responses.
Detection rules rot quietly. A rule can reference a field the ingestion pipeline renamed, a process name nobody runs any more, a Sysmon field that stopped arriving when you moved to Windows Event Forwarding, or an event schema that changed two vendor versions ago. Nothing breaks loudly. The rule stays in the repository, keeps its ATT&CK tags, keeps appearing in coverage dashboards, keeps counting as audit evidence — and never fires again.
Nullfire compares your Sigma rules against a sample of the logs you actually have, after your normalization pipeline, and tells you which rules still have a viable matching path.
pip install -e .# 1. What is actually in your data? (start here, always)
nullfire profile --logs events.jsonl# 2. Which rules can fire against it?
nullfire analyze --rules rules/ --logs events.jsonl \
--pipeline pipeline.yml \
--declare-source "product=windows,category=process_creation"Or run the bundled demo, which needs no data of your own:
nullfire analyze --config demo/config/nullfire.ymlSee what that prints
NULLFIRE
Detection Matchability Analyzer
Rules analyzed 20
Logs analyzed 1,200
Pipeline nullfire-demo-ecs-windows (file: ecs-windows.yml)
Declared sources product=windows, category=process_creation; ...
MATCHABLE 7
DEGRADED 5
NULLFIRE 4
UNASSESSED 2 (not a finding)
ERRORS 2 (operational, not detection findings)
ATT&CK impact
-------------
T1059.001 1 of 3 rule(s) cannot match [execution]
T1003.001 1 of 2 rule(s) cannot match [credential-access]
T1078.003 1 of 1 rule(s) cannot match [persistence]
T1548.002 1 of 1 rule(s) cannot match [privilege-escalation]
4 rule(s) across 4 ATT&CK technique(s) currently contribute claimed rule
coverage but cannot match the supplied data. Coverage dashboards counting
those rules are overstating what is able to fire.
NULLFIRE rules
--------------
Office Application Spawning Script Interpreter
id 4f8b1a20-0006-4c6a-9c11-a1b2c3d40006
path windows/nullfire_parent_command_line.yml
attack T1059.001
reason The rule requires 'process.parent.command_line', which does not
appear in any of the 1,200 supplied events. The dataset is
declared to contain this rule's log source, so the field is
absent rather than merely unrepresented: no event produced by
this pipeline can satisfy the rule as written.
missing process.parent.command_line
fixture minimal event that would satisfy this rule:
{
"event.code": 4688,
"process.executable": "\\cmd.exe",
"process.parent.command_line": "OUTLOOK.EXE"
}
CI policy
---------
DEGRADED <= 20 PASS
NULLFIRE <= 0 FAIL
mode fail (violations cause a non-zero exit)
RESULT: FAIL
| Status | Meaning | |
|---|---|---|
| 🟢 | MATCHABLE | Every field the rule needs exists, and each decidable predicate had a matching value observed. There is a viable matching path. Not a claim that the detection is good. |
| 🟡 | DEGRADED | A path may exist, but the evidence is weak — a literal was never observed, a field is sparse, only some branches resolve, or part of the logic is undecidable. |
| 🔴 | NULLFIRE | A required dependency cannot be satisfied against the observed schema. No event this pipeline produces can satisfy this rule. |
| ⚪ | UNASSESSED | The rule's log source is not represented in your sample. This is not a finding. Nullfire had nothing to judge it against. |
Operational failures get their own bucket and never wear a detection
status — PARSE_ERROR, PIPELINE_ERROR, VERIFICATION_ERROR,
CONFIG_ERROR. A rule that fails to parse has no verdict at all. That
separation is enforced by an assertion in the data model, not by convention.
Rule requires: process.executable endswith '\powershell.exe'
Observed: process.executable present in 67.5% of events
powershell.exe observed 0 times
pwsh.exe observed 116 times
Verdict: 🟡 DEGRADED
The field is healthy. This environment moved to PowerShell 7,
so the literal has never appeared — but a sample cannot prove
it never will. "Not observed in supplied data" means exactly
that, and nothing more.
Being precise about this matters more than the feature list.
Important
- Nullfire does not prove a detection is correct. A rule can be MATCHABLE and still be badly written, mis-scoped or trivially evadable.
- Nullfire does not prove an attack would be detected in production. It reads a sample, not your live pipeline, and reasons about schema and observed values — not about adversary behaviour.
- Nullfire does not replace detection testing or adversary simulation. It tells you which rules are worth testing.
It is a rule/data compatibility validator. Narrow job, done carefully.
flowchart TD
A["nullfire.yml<br/>config"] -->|"strict validation<br/>unknown keys are fatal"| P
B["pipeline.yml<br/>Sigma processing"] --> P["pipeline.py"]
P -->|"FAILURE ABORTS THE RUN"| STOP(["PIPELINE_ERROR<br/>exit 2 · nothing judged"])
C["events.jsonl<br/>post-pipeline logs"] --> PR["profile.py<br/>stream · flatten · bound"]
D["rules/<br/>Sigma corpus"] --> R["rules.py<br/>one file at a time"]
R -->|"apply pipeline<br/>→ effective rule"| K["constraints.py<br/>AST → constraint tree"]
K --> S["satisfy.py<br/>four-valued lattice"]
K --> F["fixture.py<br/>minimal event<br/>+ concrete verification"]
PR --> S
S --> V["verdict.py<br/>gating + interlocks"]
V --> OUT["report.py<br/>text · JSON"]
F --> OUT
OUT --> CLI(["cli.py<br/>exit 0/1/2/3/4"])
style STOP fill:#4a1010,stroke:#c04040,color:#fff
style S fill:#10304a,stroke:#4090c0,color:#fff
style V fill:#10304a,stroke:#4090c0,color:#fff
style F fill:#103a20,stroke:#40a060,color:#fff
The four-valued lattice — why three values are not enough
Every leaf predicate evaluates to one of four values, ordered:
UNSAT < UNKNOWN < WEAK < SAT
| Value | Meaning |
|---|---|
SAT |
Field exists and a satisfying value was observed |
WEAK |
Field exists, but the required value was never observed, or the field is too sparse to rely on |
UNKNOWN |
Undecidable statically — a regex, a fieldref, or a value set we stopped collecting |
UNSAT |
The field appears nowhere in the observed schema |
Then the whole engine is two lines:
AND = min OR = max
"I proved this cannot match", "I could not decide", and "this could match but I saw no evidence" are three genuinely different states. Collapsing any two produces either false confidence or useless output.
The useful properties fall out rather than being special-cased:
- An absent optional
ORbranch cannot sink a healthy rule —max(UNSAT, SAT) = SAT - One unsatisfiable conjunct is enough —
min(SAT, SAT, UNSAT) = UNSAT - Uncertainty propagates instead of being rounded away —
min(SAT, UNKNOWN) = UNKNOWN
NOT is asymmetric, deliberately. Sigma NOT branches are filters — they
subtract matches, they essentially never make a rule impossible. So negation
never returns UNSAT, except via one narrow provable check: a conjunction
that requires F == V and simultaneously excludes exactly F == V.
Declaring a rule dead because of a filter clause would be the worst false positive this tool could produce. Filters are the most-edited part of a rule, and an engineer told their rule was dead because of an exclusion would rightly stop trusting the output.
Full detail: docs/verdicts.md
How a NULLFIRE verdict is earned — two interlocks, both fail safe
NULLFIRE is the verdict that makes somebody open a ticket at 2am, so it sits
behind two independent gates. Both only ever downgrade.
1 · You must declare what your data is.
A rule requiring EventID judged against data containing only event.code
looks exactly like a rule written for the wrong log source. Both present as
total field absence. Only you can break that tie:
data_sources:
- product: windows
category: process_creation
- product: windows
service: securityWithout a declaration, the worst available verdict is DEGRADED, and every
downgraded rule carries a note saying so.
An omitted dimension means unspecified, not wildcard — declaring only
product: windows will not license NULLFIRE for every Windows rule regardless
of category.
2 · The sample must be big enough.
Below min_sample_size (default 50) events, verdicts cap at DEGRADED. You
cannot conclude a rule is dead from a three-line log file.
The one exception: a rule that contradicts itself — requiring and excluding the same literal — is reported NULLFIRE regardless. That conclusion is a property of the rule and does not depend on your data at all.
Field mapping — why raw rules are never compared to logs
raw rule → Sigma processing pipeline → effective rule → data comparison
A rule saying EventID: 4688 and an event saying event.code: "4688" are
compatible if your pipeline maps them. Nullfire loads a real pySigma
processing pipeline and reports verdicts against the effective rule — the
one actually compared against data.
nullfire analyze --rules rules/ --logs events.jsonl \
--pipeline demo/pipeline/ecs-windows.ymlPipelines are plain pySigma YAML — no plugin install, no network. An installed pySigma pipeline name works too.
⚠️ If a pipeline cannot be loaded, Nullfire analyses nothing. It reports aPIPELINE_ERRORand exits 2. A typo'd pipeline name that instead reported three thousand rules as dead would look exactly like a catastrophic outage, and that is the single worst output this tool could produce.
Minimal fixtures — turn "this rule is dead" into a regression test
For NULLFIRE and DEGRADED rules, Nullfire generates the smallest synthetic event that satisfies the detection logic.
{
"rule_id": "4f8b1a20-0006-4c6a-9c11-a1b2c3d40006",
"status": "NULLFIRE",
"fixture": {
"event.code": 4688,
"process.executable": "\\cmd.exe",
"process.parent.command_line": "OUTLOOK.EXE"
}
}Three fields, nothing else. Drop it into a detection test and the rule fails until the underlying gap is fixed.
Two guarantees:
- Nothing is emitted unverified. Every candidate is evaluated against the
rule's own constraint tree with real boolean semantics — including true
negation — before it is offered. This is a separate evaluator from the
lattice one, precisely because the lattice treats every
NOTas satisfiable. - Refusal is a valid answer. Regex,
fieldref, unresolved placeholders and irreconcilable field requirements each produce an explicit "not generated, because…" rather than a plausible-looking guess.
Fixtures are generated from the rule alone, never the data — so a fixture
cannot leak log content, which is also why they survive --redact.
Values are either literals the rule itself requires or the inert placeholder
NULLFIRE_TEST. No credentials, tokens, payloads or destructive commands are
synthesised, and nothing is ever executed.
nullfire analyze --config nullfire.yml --fixtures-out out/fixtures.jsonlATT&CK coverage impact — which techniques are propped up by dead rules
T1059.001 1 of 3 rule(s) cannot match [execution]
T1003.001 1 of 2 rule(s) cannot match [credential-access]
This is rule-inventory coverage, not detection capability. The question it answers is: which techniques are being falsely represented by dead rules?
📌 A technique with ten MATCHABLE rules is not "detected" — those rules may be badly written. A technique with three NULLFIRE rules is not "undetected" — something else may cover it. What the rollup does tell you is where a coverage dashboard is counting rules that cannot fire.
UNASSESSED rules are reported separately and never counted as failures. A
technique whose rules are all UNASSESSED means your sample was incomplete.
The disclaimer travels in the JSON output too, under attack.note — please
carry it through if you render this anywhere.
nullfire analyze --config demo/config/nullfire.ymlTwenty hand-written rules over 1,200 synthetic ECS-shaped Windows events. Each rule file is named for the verdict it exists to provoke, and a test asserts all twenty against their filenames — so these numbers cannot drift out of step with the code.
Now run it again with the data-source declaration removed:
nullfire analyze --config demo/config/nullfire-undeclared.ymlSame rules. Same events. Same pipeline. The only difference is one config block:
| declared | undeclared | |
|---|---|---|
| 🟢 MATCHABLE | 7 | 7 |
| 🟡 DEGRADED | 5 | 7 |
| 🔴 NULLFIRE | 4 | 1 |
| ⚪ UNASSESSED | 2 | 3 |
Three of the four dead rules become DEGRADED or UNASSESSED, each carrying a note explaining that declaring the data source would sharpen the verdict.
That is the design, not a bug. A rule requiring a field that appears nowhere in your data is genuinely indistinguishable from a rule written for a different log source. Only you can break that tie. Until you do, Nullfire refuses to make the stronger claim.
The one NULLFIRE that survives is the self-contradictory rule — whose deadness is a property of the rule, not of your data.
→ demo/README.md walks through all twenty rules and why each gets its verdict.
Full annotated config
rules_dir: ./rules
log_file: ./events.jsonl
pipeline: ./pipeline/ecs-windows.yml
# What this dataset actually IS. Declaring it is what licenses a NULLFIRE
# verdict. An omitted dimension means "unspecified", not "any".
data_sources:
- product: windows
category: process_creation
min_sample_size: 50 # below this, verdicts cap at DEGRADED
sparse_field_threshold: 0.02 # occupancy at or below which a field is sparse
max_distinct_values: 200 # per-field value-set cap
max_value_length: 4096 # per-value truncation
example_limit: 5 # example values shown per field
redact_values: false # true withholds observed values from the report
generate_fixtures: true
max_events: 0 # 0 = no cap
ignored_rules: [] # by rule id or title
ignored_sources: [] # e.g. ["linux/auditd"]
policy:
mode: fail # record | warn | fail
nullfire: { max: 0 }
degraded: { max: 20 }
unassessed: { max: 50 }
errors: { max: 5 }Validation is strict: unknown keys are fatal, and so are wrong types and
out-of-range values. A gate configured with nullfir: {max: 0} that silently
ignored the typo would report success forever. Every problem is reported at
once, so fixing a config is one round trip.
An unconfigured threshold has no ceiling — None and 0 mean different
things.
- run: nullfire analyze --config nullfire.yml| Exit | Meaning |
|---|---|
0 |
Completed; no enforced threshold exceeded |
1 |
Completed; an enforced threshold was exceeded — the gate |
2 |
Configuration or operational error: bad config, unloadable pipeline, empty log dataset |
3 |
Rule processing failure — too many rules failed to parse for the result to mean anything |
4 |
Unexpected internal error (a bug) |
Tip
The split between 1 and 2 is the important one. A pipeline that treats
"found a problem" and "could not run" identically eventually goes green
because a path was mistyped, and nobody notices until it matters. An empty
log file exits 2, not 0.
policy.mode decides whether a violation reaches the exit code at all:
record counts it, warn reports it prominently, fail blocks the build.
All input is untrusted — rule files, log files, pipelines and configuration alike. Rule repositories get cloned from the internet, and log files contain whatever an attacker managed to put into a log field.
| Property | |
|---|---|
| ⛔ | No rule or log content is ever executed. No shell, no subprocess, no eval, no exec. |
| ⛔ | No regex from a rule file is ever compiled. Sigma wildcards are matched by a hand-written two-pointer matcher, O(n·m) worst case. Turning *a*b*c* into .*a.*b.*c.* and handing it to a regex engine would reintroduce catastrophic backtracking reachable from a rule file. Sigma's |re is reported undecidable instead. |
| 🔒 | YAML via safe_load only, in Nullfire and pySigma. Pipelines load with allow_template_vars=False and allow_external_sources=False passed explicitly, so a future default change cannot quietly widen this. |
| 📏 | Every input axis is bounded — streaming, per-line size, per-value length, distinct values per field, distinct field paths, nesting depth, tree depth and node count. And each bound is tracked, so hitting one weakens conclusions rather than silently changing them. |
| 🚧 | No path escapes the rules directory. Symlinks are not followed; candidates are fully resolved and containment-checked. |
| 🕵️ | Reports do not leak the host. Paths are relativized; a path outside the known roots degrades to its file name, never to an absolute path. |
| 🏠 | Logs stay local. No sockets, no telemetry, no upload. Malformed log lines are never quoted back in errors — that is exactly the content most likely to hold a secret. --redact withholds observed values entirely. |
These are asserted by tests/test_security_properties.py, which walks the
source AST — so a future change introducing exec, an unsafe YAML load, a
socket, or a regex in the matching path fails the build rather than quietly
falsifying this table.
→ docs/threat-model.md · SECURITY.md
What Nullfire cannot tell you — read before acting on a report
- Correlation rules are recorded but not analysed. Temporal windows, event counts and field grouping are out of scope, and guessing at them would produce confident nonsense.
- Regex and
fieldrefare undecidable by design and reported as such rather than approximated. NOTbranches never yield NULLFIRE except via one narrow provable contradiction check — so Nullfire under-reports contradictions on purpose.- Verdicts describe the supplied sample, not production. A field absent from your sample may exist in your pipeline.
- Keyword matching is approximated as containment. Real SIEM full-text indexing involves tokenisation Nullfire does not model, so keyword verdicts are the least precise it produces.
- Fixture values follow the rule, not the data. A rule saying
EventID: 4688yields the integer4688even if your pipeline emits the string"4688".
Explicitly out of scope: live SIEM queries · attack simulation · EVTX parsing · automatic rule fixing · false-positive tuning · endpoint deployment · replacing a SIEM.
Full detail: docs/limitations.md
| docs/verdicts.md | The lattice, the gates, and how each status is reached |
| docs/architecture.md | Module layout, data flow, design decisions |
| docs/json-schema.md | JSON report schema and how to consume it |
| docs/threat-model.md | Threats and mitigations |
| docs/limitations.md | What Nullfire cannot tell you |
| demo/README.md | All twenty demo rules, and why each gets its verdict |
python -m venv .venv
. .venv/Scripts/activate # Windows
# source .venv/bin/activate # Linux / macOS
pip install -e .Python 3.10+. One direct runtime dependency: pySigma — Nullfire does not implement its own Sigma parser. The 3.10 floor comes from pySigma.
nullfire profile --logs events.jsonl # discover your real schema
nullfire analyze --config nullfire.yml # the main command
nullfire analyze --config nullfire.yml --json # machine-readable
nullfire validate --config nullfire.yml # check a config, run nothingUseful flags
| Flag | Effect |
|---|---|
--declare-source "product=windows,category=process_creation" |
Declare the dataset inline; repeatable. This is what licenses NULLFIRE verdicts. |
--fail-on-nullfire N |
Fail the build above N dead rules (implies mode: fail) |
--fixtures-out PATH |
Write generated fixtures as a JSONL regression corpus |
--redact |
Withhold all observed log values — safe to attach to a ticket |
--show-profile |
Include the observed-data profile in the text report |
--detail-limit N |
Rules shown per detail section (0 for summary only) |
--output PATH |
Write the report to a file |
git clone --depth 1 https://github.com/SigmaHQ/sigma.git
nullfire analyze \
--rules sigma/rules \
--logs demo/logs/windows-ecs.jsonl \
--pipeline demo/pipeline/ecs-windows.yml \
--declare-source "product=windows,category=process_creation" \
--declare-source "product=windows,service=security" \
--declare-source "product=windows,service=system"Expect a large UNASSESSED count — the demo dataset holds four event types
and SigmaHQ covers hundreds. That is the correct answer, and it is why
UNASSESSED exists as a status rather than being folded into a failure count.
SigmaHQ is licensed under the Detection Rule License and is not bundled here. Clone it yourself.
pip install -e ".[dev]"
pytest # 424 tests
ruff check src tests
mypyValidated against a real corpus: 3,144 SigmaHQ rules analysed in ~22 s on a laptop, with zero parse failures. Every NULLFIRE verdict in that run was audited to confirm it was driven by a genuinely absent field — no false positives, and no verdict without an explanation.
A detection rule that has never matched may be perfectly correct — or it may be impossible to match against your data pipeline.
Nullfire exists to tell you which.
MIT · Het Patel