Implements the rule engine body left empty at contract phase: evaluate() dispatches by source_kind through self._by_kind, runs the rule's match spec against event.payload, and emits one TTPTag per emits entry. watch_store() loads the initial corpus from RuleStore.load_compiled, then drains subscribe_changes, applying definition changes via single-statement dict assignment (atomic swap, GIL-atomic to readers) and state changes via NamedTuple._replace on the existing CompiledRule. Why: with the FS + DB stores in place (E.3.5/E.3.6), the engine is the last piece of the rule plane. Lifters (E.3.9–E.3.13) consume the engine; the worker bootstrap (E.3.14) wires watch_store into the asyncio event loop. After this commit a CompositeTagger constructed with a RuleEngine + a populated rules dir will produce real tags. Notes: - CompiledRule.emits extended to 4-tuple (technique_id, sub_technique_id, tactic, confidence). Tactic + confidence ride per-emit so a single rule can carry multiple precision targets (the "one event maps to many techniques" property). Compile helpers in both backends extract them from the YAML emits dict; missing tactic or confidence is a deploy-time error. - v0 match operator is "pattern" (regex). The field defaults per source_kind (command_text / raw_url / subject / verdict / …) and is overridable via match.field. Future ops (contains, equals, in_set) extend _match_event without touching the engine surface. - Confidence model: rules with state="clipped" + confidence_max set cap the per-emit confidence downward; clipped is a soft suppress, not a hard skip. Disabled rules are skipped wholly; expires_at past is re-checked at evaluate as defense-in-depth (the store auto-reverts, but a racing read between expiry and revert must not fire the rule). - _span(name, **attrs) helper in engine + both stores short-circuits on decnet.telemetry._ENABLED — matches the project's @traced / wrap_repository zero-overhead-when-disabled pattern instead of relying solely on the no-op tracer indirection. - Late-bound tracer (telemetry.get_tracer called per-span, not at module load) so test_tracing's monkeypatch reaches the production code path. xfails flipped: tests/ttp/test_rule_engine.py multi-emit fan-out + rule_version-collision-via-engine; tests/ttp/test_multi_mapping.py N×M engine fan-out + idempotent replay; tests/ttp/test_tracing.py ttp.eval span hierarchy + ttp.rule.fire span attributes. Tests: 214 passed, 19 xfailed (gated on E.3.8 lifters / rule pack / worker bootstrap). mypy: clean on prod code; pre-existing test-stub arg-type warnings unchanged.
215 lines
8.1 KiB
Python
215 lines
8.1 KiB
Python
"""E.2.14b — Cross-backend conformance for :class:`RuleStore`.
|
|
|
|
Both :class:`FilesystemRuleStore` and :class:`DatabaseRuleStore` must
|
|
satisfy the same observable contract. The :func:`rule_store` fixture
|
|
in :mod:`conftest` parametrizes every assertion in this module over
|
|
both backends.
|
|
|
|
Per ``development/TTP_TAGGING.md`` §E.2.14b:
|
|
|
|
* :meth:`load_compiled` over a known YAML corpus returns the same
|
|
``CompiledRule`` set from both backends (modulo state defaulting
|
|
to enabled when no state row exists).
|
|
* :meth:`get_state` for an unknown ``rule_id`` returns the default
|
|
``RuleState(state="enabled", ...)`` — never raise, never return
|
|
``None``.
|
|
* :meth:`set_state` on one ``rule_id`` does not affect any other.
|
|
* :meth:`set_state` followed by :meth:`get_state` round-trips
|
|
faithfully.
|
|
* :meth:`subscribe_changes` yields ONE :class:`RuleChange` per
|
|
per-rule edit (5-rule edit → 5 events, never one batch of 5).
|
|
* ``expires_at`` in the past → :meth:`get_state` returns the
|
|
default and emits a ``ttp.rule.state.{rule_id}`` auto-revert
|
|
event.
|
|
* :meth:`set_state` failure (DB write error) raises rather than
|
|
silently dropping — operational state changes are not a
|
|
tolerated-absence path.
|
|
|
|
Filesystem-specific properties (inotify mask, dotfile filter,
|
|
atomic-swap concurrency) live in :mod:`test_filesystem`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from decnet.ttp.impl.rule_engine import CompiledRule
|
|
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
|
|
|
|
from .conftest import seed_rule
|
|
|
|
|
|
_RULE_YAML = """\
|
|
rule_id: {rule_id}
|
|
rule_version: 1
|
|
name: test rule
|
|
applies_to: [command]
|
|
match:
|
|
pattern: 'hydra'
|
|
emits:
|
|
- tactic: TA0006
|
|
technique_id: T1110
|
|
confidence: 0.85
|
|
"""
|
|
|
|
|
|
# ── Surface (GREEN today) ───────────────────────────────────────────
|
|
|
|
|
|
def test_store_implements_required_methods(rule_store: RuleStore) -> None:
|
|
"""Every backend implements all four ABC methods. Catches a
|
|
refactor that accidentally drops a method body."""
|
|
for name in ("load_compiled", "get_state", "set_state", "subscribe_changes"):
|
|
assert hasattr(rule_store, name)
|
|
|
|
|
|
def test_async_methods_are_coroutines() -> None:
|
|
"""The three ``async def`` methods on the ABC are coroutine
|
|
functions; ``subscribe_changes`` is a regular ``def`` returning
|
|
an async iterator (per the doc's signature)."""
|
|
for name in ("load_compiled", "get_state", "set_state"):
|
|
member = getattr(RuleStore, name)
|
|
assert inspect.iscoroutinefunction(member), (
|
|
f"RuleStore.{name} must be `async def`"
|
|
)
|
|
|
|
|
|
def test_rule_change_namedtuple_shape() -> None:
|
|
""":class:`RuleChange` carries ``change_kind``, ``rule_id``,
|
|
``new_value`` — pinned so a future "improvement" that adds
|
|
fields trips downstream consumers (the bus republisher, the
|
|
engine's atomic swap path) deliberately rather than silently."""
|
|
assert RuleChange._fields == ("change_kind", "rule_id", "new_value")
|
|
|
|
|
|
# ── Default state behavior ──────────────────────────────────────────
|
|
|
|
|
|
async def test_get_state_unknown_returns_default(rule_store: RuleStore) -> None:
|
|
"""``get_state`` for a never-set ``rule_id`` returns the default
|
|
``RuleState`` — never raises, never returns ``None``."""
|
|
state = await rule_store.get_state("R0001_unknown_rule")
|
|
assert state == RuleState()
|
|
assert state.state == "enabled"
|
|
|
|
|
|
# ── Behavioral conformance (xfail until E.3.5/E.3.6) ────────────────
|
|
|
|
|
|
async def test_load_compiled_corpus_identical_across_backends(
|
|
rule_store: RuleStore, tmp_path: Path,
|
|
) -> None:
|
|
"""Both backends, given the same YAML corpus, return the same
|
|
set of ``CompiledRule`` (modulo state defaulting). The doc's
|
|
cross-backend property requires running the same fixture against
|
|
both — pinned here as a single test that the parametrize fans
|
|
out over both backends."""
|
|
await seed_rule(rule_store, "R0001", _RULE_YAML.format(rule_id="R0001"))
|
|
await seed_rule(rule_store, "R0002", _RULE_YAML.format(rule_id="R0002"))
|
|
compiled = await rule_store.load_compiled()
|
|
assert {c.rule_id for c in compiled} == {"R0001", "R0002"}
|
|
for c in compiled:
|
|
assert isinstance(c, CompiledRule)
|
|
assert c.state == RuleState()
|
|
assert c.applies_to == frozenset({"command"})
|
|
assert c.emits == (("T1110", None, "TA0006", 0.85),)
|
|
|
|
|
|
async def test_set_state_isolates_rules(rule_store: RuleStore) -> None:
|
|
"""``set_state(A, ...)`` does not perturb the state read by
|
|
``get_state(B)``."""
|
|
await rule_store.set_state(
|
|
"R0001", RuleState(state="disabled", reason="A"), set_by="op",
|
|
)
|
|
other = await rule_store.get_state("R0002")
|
|
assert other == RuleState() # B untouched
|
|
|
|
|
|
async def test_set_state_then_get_state_round_trips(
|
|
rule_store: RuleStore,
|
|
) -> None:
|
|
"""``set_state`` followed by ``get_state`` returns the value
|
|
that was set. No translation, no field drop."""
|
|
new_state = RuleState(
|
|
state="clipped", confidence_max=0.5, reason="probation",
|
|
)
|
|
await rule_store.set_state("R0001", new_state, set_by="op")
|
|
got = await rule_store.get_state("R0001")
|
|
assert got.state == "clipped"
|
|
assert got.confidence_max == 0.5
|
|
assert got.reason == "probation"
|
|
assert got.set_by == "op"
|
|
assert got.set_at is not None
|
|
|
|
|
|
async def test_subscribe_changes_per_rule_not_batched(
|
|
rule_store: RuleStore,
|
|
) -> None:
|
|
"""A 5-rule edit produces 5 :class:`RuleChange` events from
|
|
:meth:`subscribe_changes`, never a single event carrying 5
|
|
entries. The bus per-rule fan-out
|
|
(``ttp.rule.reloaded.{rule_id}``) inherits its granularity from
|
|
this iterator."""
|
|
sub = rule_store.subscribe_changes()
|
|
for i in range(5):
|
|
await rule_store.set_state(
|
|
f"R000{i}", RuleState(state="disabled"), set_by="op",
|
|
)
|
|
seen: list[RuleChange] = []
|
|
for _ in range(5):
|
|
seen.append(await asyncio.wait_for(sub.__anext__(), timeout=2.0))
|
|
rule_ids = {ch.rule_id for ch in seen}
|
|
assert rule_ids == {f"R000{i}" for i in range(5)}
|
|
for ch in seen:
|
|
assert ch.change_kind == "state"
|
|
assert isinstance(ch.new_value, RuleState)
|
|
|
|
|
|
async def test_expired_state_reverts_to_default_and_emits(
|
|
rule_store: RuleStore,
|
|
) -> None:
|
|
"""A ``RuleState`` with ``expires_at`` in the past returns the
|
|
default from :meth:`get_state` AND emits a
|
|
``ttp.rule.state.{rule_id}`` auto-revert event."""
|
|
past = datetime.now(tz=timezone.utc) - timedelta(seconds=5)
|
|
sub = rule_store.subscribe_changes()
|
|
await rule_store.set_state(
|
|
"R0001",
|
|
RuleState(state="disabled", expires_at=past),
|
|
set_by="op",
|
|
)
|
|
# Drain the set_state event we just produced.
|
|
await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
|
state = await rule_store.get_state("R0001")
|
|
assert state == RuleState()
|
|
revert = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
|
assert revert.change_kind == "state"
|
|
assert revert.rule_id == "R0001"
|
|
assert revert.new_value == RuleState()
|
|
|
|
|
|
async def test_set_state_failure_raises_not_silent(
|
|
rule_store: RuleStore,
|
|
) -> None:
|
|
"""A backend failure during :meth:`set_state` (e.g. queue
|
|
death) MUST raise rather than silently drop. Operational state
|
|
changes are NOT a tolerated-absence path — state drift would be
|
|
silent and dangerous."""
|
|
|
|
class _BoomQueue:
|
|
async def put(self, _item: object) -> None:
|
|
raise RuntimeError("simulated backend failure")
|
|
|
|
# Inject a poisoned subscriber so the publish path raises.
|
|
if not hasattr(rule_store, "_subscribers"): # pragma: no cover
|
|
pytest.skip("backend has no subscriber fan-out hook")
|
|
rule_store._subscribers.append(_BoomQueue())
|
|
with pytest.raises(RuntimeError, match="simulated backend failure"):
|
|
await rule_store.set_state(
|
|
"R0001", RuleState(state="disabled"), set_by="op",
|
|
)
|