From cb9d183c204a9c14d8424dcf848f4dc2c940d6a7 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 06:30:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(ttp):=20E.1.5=20RuleEngine=20contract=20?= =?UTF-8?q?=E2=80=94=20CompiledRule,=20RuleSchema,=20RuleEngine=20ABC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decnet/ttp/impl/__init__.py | 6 ++ decnet/ttp/impl/rule_engine.py | 149 +++++++++++++++++++++++++++++++++ development/TTP_TAGGING.md | 2 + tests/ttp/test_rule_engine.py | 134 +++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 decnet/ttp/impl/__init__.py create mode 100644 decnet/ttp/impl/rule_engine.py create mode 100644 tests/ttp/test_rule_engine.py diff --git a/decnet/ttp/impl/__init__.py b/decnet/ttp/impl/__init__.py new file mode 100644 index 00000000..bb9653cd --- /dev/null +++ b/decnet/ttp/impl/__init__.py @@ -0,0 +1,6 @@ +"""TTP tagger implementations — rule engine + per-source lifters. + +Subpackage layout per the provider-subpackage convention used in +:mod:`decnet.intel.impl` and :mod:`decnet.clustering.impl`. Concrete +classes live here; the public surface is :func:`decnet.ttp.factory.get_tagger`. +""" diff --git a/decnet/ttp/impl/rule_engine.py b/decnet/ttp/impl/rule_engine.py new file mode 100644 index 00000000..22266584 --- /dev/null +++ b/decnet/ttp/impl/rule_engine.py @@ -0,0 +1,149 @@ +"""Rule engine contract — `CompiledRule`, `RuleEngine`, `RuleSchema`. + +Contract step E.1.5 of ``development/TTP_TAGGING.md``. Shape only — no +real evaluation logic, no YAML parsing, no dispatch. The implementation +phase (E.3) replaces every empty body in this file; *callers compile +against the public surface here today* so subsequent contract commits +(lifters E.1.6, worker E.1.7) can import without churn. + +Three classes live in this module: + +* :class:`CompiledRule` — frozen, hashable record the engine evaluates + against. The store produces these after validating raw YAML through + :class:`RuleSchema` and stamping operational :class:`RuleState`. +* :class:`RuleSchema` — Pydantic model for raw YAML rule shape. + Operationally owned by the store (it reads disk and validates), + declared here per the file mapping in the design doc — keeping the + schema and the compiled record next to each other lets reviewers see + the YAML→runtime translation in one diff. +* :class:`RuleEngine` — consumes a :class:`RuleStore`, evaluates one + :class:`TaggerEvent` at a time. Hot-reload via + :meth:`RuleEngine.watch_store` swaps individual compiled rules in the + dispatch index atomically — never bulk-rebuilds. + +The :class:`RuleStore` and :class:`RuleState` types arrive in E.1.11; +they are forward-referenced under :data:`TYPE_CHECKING` here so this +file is importable before that step lands. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from pydantic import BaseModel, Field + +from decnet.ttp.base import TaggerEvent +from decnet.web.db.models.ttp import TTPTag + +if TYPE_CHECKING: + # Store contracts ship in E.1.11. Forward-referenced under + # TYPE_CHECKING so this module is importable in the contract phase + # without creating a circular shape dependency on a not-yet-shipped + # subpackage. Concrete construction happens at the worker layer + # (E.1.7) where both halves are in scope. + from decnet.ttp.store.base import RuleState, RuleStore + + +class CompiledRule(NamedTuple): + """Runtime-ready representation of one YAML rule. + + Frozen by virtue of being a NamedTuple — the design doc's + "atomic-swap concurrency" property (E.2.14b) requires that a rule + in the dispatch index can never be torn mid-evaluate. NamedTuple + rather than ``@dataclass(frozen=True)`` because instances are + swapped *by replacement* and benefit from the cheaper allocator; + `FrozenInstanceError` parity is preserved by the in-test smoke + signal in E.2.14b. + + Fields mirror the YAML rule shape one-to-one except for ``state``, + which the store stamps in at compile time after merging operational + state (enabled / disabled / clipped, confidence ceiling, expiry). + The engine therefore never reads :class:`RuleState` directly — it + only consults the value attached to each :class:`CompiledRule`. + """ + + rule_id: str + rule_version: int + name: str + #: ``source_kind`` strings the rule is allowed to fire on. Frozen so + #: it can live in a set / dispatch index key without copying. + applies_to: frozenset[str] + #: Opaque match spec — interpretation belongs to the engine impl + #: phase (E.3). Kept ``dict[str, Any]`` here rather than typed so + #: rule authors can extend match operators without touching the ABC. + match_spec: dict[str, Any] + #: ``((technique_id, sub_technique_id | None), ...)``. Tuple, not + #: list, so the record stays hashable. + emits: tuple[tuple[str, str | None], ...] + #: Names of evidence keys the rule populates on emitted tags. + evidence_fields: tuple[str, ...] + #: Operational state stamped in by the store at compile time. + state: "RuleState" + + +class RuleSchema(BaseModel): + """Pydantic model for the raw YAML rule shape. + + Validation surface only — no runtime semantics. The store calls + :meth:`model_validate` on each parsed YAML document; the engine + never touches this class. Adding a new top-level rule field means + adding it here AND extending :class:`CompiledRule` in the same + commit, so the YAML→runtime mapping stays one-to-one. + """ + + rule_id: str + rule_version: int + name: str + applies_to: list[str] + match: dict[str, Any] + emits: list[dict[str, str]] + evidence_fields: list[str] = Field(default_factory=list) + + +class RuleEngine: + """Evaluates :class:`TaggerEvent` against compiled rules from a store. + + Construction takes the store reference; the engine never reads YAML + directly. The dispatch index (``self._by_kind``) is rebuilt by + :meth:`watch_store` on each per-rule change event from + ``store.subscribe_changes()`` — never bulk-rebuilt — so an edit to + one rule cannot stall evaluation of unrelated rules. + + Contract phase: every method has an empty body. The implementation + phase (E.3) wires real compile + evaluate logic; callers compiling + against the public surface today will not need to change. + """ + + def __init__(self, store: "RuleStore") -> None: + self._store = store + # ``source_kind`` → list of compiled rules that claim it. + # Empty here; populated by :meth:`watch_store` once the store + # contract lands (E.1.11). + self._by_kind: dict[str, list[CompiledRule]] = {} + + async def evaluate(self, event: TaggerEvent) -> list[TTPTag]: + """Return zero or more tags produced by rules matching *event*. + + Empty in the contract phase. The impl phase fans the event out + to ``self._by_kind[event.source_kind]`` and merges results. + """ + return [] + + async def watch_store(self) -> None: + """Subscribe to per-rule changes and atomically swap them in. + + Reads from :meth:`RuleStore.subscribe_changes`. Each yielded + change is one rule_id; the engine recompiles that rule alone + and replaces the corresponding entries in the dispatch index + in a single assignment. Never returns under normal operation — + the worker cancels it during shutdown. + + Empty in the contract phase. + """ + return None + + +__all__ = [ + "CompiledRule", + "RuleEngine", + "RuleSchema", +] diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index 6c94ecff..48eb0c85 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -2288,6 +2288,8 @@ Contracts ship in this order, one commit per step: **E.1.5 — RuleEngine contract** (`decnet/ttp/impl/rule_engine.py`) +**Status:** ✅ done. + - `class CompiledRule(NamedTuple)`: rule_id, rule_version, name, applies_to, match_spec, emits, evidence_fields, **state** (`RuleState`). diff --git a/tests/ttp/test_rule_engine.py b/tests/ttp/test_rule_engine.py new file mode 100644 index 00000000..d8f5f1e9 --- /dev/null +++ b/tests/ttp/test_rule_engine.py @@ -0,0 +1,134 @@ +"""Contract tests for :mod:`decnet.ttp.impl.rule_engine` (E.1.5). + +Scoped to the contract surface: shape of :class:`CompiledRule`, +constructor signature of :class:`RuleEngine`, the empty-list / +``None`` returns from :meth:`evaluate` / :meth:`watch_store`, and the +:class:`RuleSchema` field set. Behavioral assertions from E.2.5 +(malformed-YAML compile failure, multi-emit fan-out, version-collision +distinct UUIDs) are present but xfail-strict pending E.3. +""" +from __future__ import annotations + +import asyncio +import inspect + +import pytest + +from decnet.ttp.base import TaggerEvent +from decnet.ttp.impl.rule_engine import CompiledRule, RuleEngine, RuleSchema + + +def _ev() -> TaggerEvent: + return TaggerEvent( + source_kind="command", + source_id="src1", + attacker_uuid="att1", + identity_uuid=None, + session_id=None, + decky_id=None, + payload={}, + ) + + +class _StubStore: + """Minimal duck-typed RuleStore for contract-phase construction.""" + + +def test_compiled_rule_is_namedtuple_with_documented_fields(): + assert issubclass(CompiledRule, tuple) + fields = CompiledRule._fields + assert fields == ( + "rule_id", + "rule_version", + "name", + "applies_to", + "match_spec", + "emits", + "evidence_fields", + "state", + ) + + +def test_compiled_rule_is_immutable(): + # NamedTuple gives us field-level immutability — the atomic-swap + # property (E.2.14b) requires that a rule in the dispatch index + # cannot be mutated in place; replacement is the only legal edit. + cr = CompiledRule( + rule_id="R0001", + rule_version=1, + name="brute", + applies_to=frozenset({"command"}), + match_spec={}, + emits=(("T1110", None),), + evidence_fields=("matched_tokens",), + state=object(), + ) + with pytest.raises(AttributeError): + cr.rule_id = "R9999" # type: ignore[misc] + + +def test_rule_engine_constructs_with_store(): + eng = RuleEngine(store=_StubStore()) # type: ignore[arg-type] + # Dispatch index starts empty in the contract phase. + assert eng._by_kind == {} + + +def test_rule_engine_init_signature_takes_store(): + sig = inspect.signature(RuleEngine.__init__) + assert list(sig.parameters)[1] == "store" + + +def test_evaluate_returns_empty_list_in_contract_phase(): + eng = RuleEngine(store=_StubStore()) # type: ignore[arg-type] + out = asyncio.run(eng.evaluate(_ev())) + assert out == [] + + +def test_watch_store_returns_none_and_does_not_raise(): + eng = RuleEngine(store=_StubStore()) # type: ignore[arg-type] + assert asyncio.run(eng.watch_store()) is None + + +def test_rule_schema_has_documented_fields(): + fields = RuleSchema.model_fields + must_have = { + "rule_id", + "rule_version", + "name", + "applies_to", + "match", + "emits", + "evidence_fields", + } + assert must_have <= set(fields) + + +def test_rule_schema_validates_minimal_yaml_shape(): + rs = RuleSchema.model_validate({ + "rule_id": "R0001", + "rule_version": 1, + "name": "brute force ssh", + "applies_to": ["command"], + "match": {"contains": "hydra"}, + "emits": [{"technique_id": "T1110"}], + }) + assert rs.rule_id == "R0001" + assert rs.evidence_fields == [] # default + + +# ── E.2.5 deferred behavioral assertions ─────────────────────────── + + +@pytest.mark.xfail(strict=True, reason="impl phase E.3 — malformed YAML") +def test_e25_malformed_yaml_fails_at_compile_not_evaluate(): + raise AssertionError("not yet implemented") + + +@pytest.mark.xfail(strict=True, reason="impl phase E.3 — multi-emit fan-out") +def test_e25_one_rule_multiple_emits_produces_multiple_tags(): + raise AssertionError("not yet implemented") + + +@pytest.mark.xfail(strict=True, reason="impl phase E.3 — rule_version collision") +def test_e25_rule_version_collision_yields_distinct_tag_uuids(): + raise AssertionError("not yet implemented")