feat(ttp): E.3.5 FilesystemRuleStore — inotify hot-reload + per-rule events
Implements the filesystem-backed rule store body left empty at contract phase: YAML parse + Pydantic validation, asyncinotify watch over ./rules/ttp/, in-process state cache with auto-revert on expires_at, and a subscribe_changes() async iterator yielding one RuleChange per per-rule edit. Bus topic builders ttp_rule_reloaded / ttp_rule_state ship alongside. Why: the rule plane needed a store before the engine (E.3.7) could consume RuleChange events and atomically swap compiled rules into its dispatch index. Notes: - Linux-only by construction (asyncinotify wheel gated by sys_platform marker; FilesystemRuleStore.__init__ raises on non-Linux). - Filename allowlist is the FIRST check on every inotify event. - Content-hash dedup so a single write firing IN_CREATE + IN_CLOSE_WRITE produces exactly one RuleChange. - All compile work serializes on a single asyncio.Lock. - Subscribers register their queue eagerly so events fired between subscribe_changes() and the first __anext__() are buffered. xfails flipped: per-save-style + filter-ordering + atomic-swap in test_filesystem.py; load_compiled / set_state isolation / round-trip / per-rule fan-out / expired-state revert / set_state failure semantics in test_conformance.py (FS side; DB side stays xfail until E.3.6); malformed-YAML compile-time check in test_rule_engine.py. Tests: 197 passed, 35 xfailed (gated on E.3.6 / E.3.7 / lifters). mypy + bandit: clean on all touched files. Wiki update for the per-rule reload + state-change topics lands in a matching wiki-checkout/Service-Bus.md edit (separate repo).
This commit is contained in:
@@ -21,6 +21,8 @@ def test_ttp_leaf_constants() -> None:
|
||||
assert topics.TTP_TAGGED == "tagged"
|
||||
assert topics.TTP_RULE_FIRED == "rule.fired"
|
||||
assert topics.TTP_RULE_SUPPRESSED == "rule.suppressed"
|
||||
assert topics.TTP_RULE_RELOADED == "rule.reloaded"
|
||||
assert topics.TTP_RULE_STATE == "rule.state"
|
||||
|
||||
|
||||
def test_email_received_is_one_nats_token() -> None:
|
||||
@@ -46,6 +48,28 @@ def test_ttp_rule_fired_per_technique() -> None:
|
||||
assert topics.ttp_rule_fired("T1059") == "ttp.rule.fired.T1059"
|
||||
|
||||
|
||||
def test_ttp_rule_reloaded_per_rule() -> None:
|
||||
assert topics.ttp_rule_reloaded("R0001") == "ttp.rule.reloaded.R0001"
|
||||
assert topics.ttp_rule_reloaded("R9999") == "ttp.rule.reloaded.R9999"
|
||||
|
||||
|
||||
def test_ttp_rule_state_per_rule() -> None:
|
||||
assert topics.ttp_rule_state("R0001") == "ttp.rule.state.R0001"
|
||||
assert topics.ttp_rule_state("R0042") == "ttp.rule.state.R0042"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "has.dot", "has*wild", "has>wild", "with space"])
|
||||
def test_ttp_rule_reloaded_rejects_bad_segments(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.ttp_rule_reloaded(bad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "has.dot", "has*wild", "has>wild", "with space"])
|
||||
def test_ttp_rule_state_rejects_bad_segments(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.ttp_rule_state(bad)
|
||||
|
||||
|
||||
def test_email_topic_builder() -> None:
|
||||
assert topics.email_topic(topics.EMAIL_RECEIVED) == "email.received"
|
||||
|
||||
@@ -63,11 +87,23 @@ def test_ttp_builder_rejects_empty() -> None:
|
||||
"ttp.rule.fired",
|
||||
"ttp.rule.fired.T1110",
|
||||
"ttp.rule.suppressed",
|
||||
"ttp.rule.reloaded.R0001",
|
||||
"ttp.rule.state.R0001",
|
||||
])
|
||||
def test_ttp_wildcard_matches_every_documented_topic(topic: str) -> None:
|
||||
assert matches("ttp.>", topic) is True
|
||||
|
||||
|
||||
def test_ttp_rule_reloaded_wildcard_per_rule() -> None:
|
||||
assert matches("ttp.rule.reloaded.>", "ttp.rule.reloaded.R0001") is True
|
||||
assert matches("ttp.rule.reloaded.>", "ttp.rule.reloaded") is False
|
||||
|
||||
|
||||
def test_ttp_rule_state_wildcard_per_rule() -> None:
|
||||
assert matches("ttp.rule.state.>", "ttp.rule.state.R0001") is True
|
||||
assert matches("ttp.rule.state.>", "ttp.rule.state") is False
|
||||
|
||||
|
||||
def test_ttp_wildcard_excludes_root() -> None:
|
||||
# ``>`` requires AT LEAST one trailing token. The bare root
|
||||
# ``ttp`` must not match — pinned so a regression in
|
||||
|
||||
Reference in New Issue
Block a user