feat(ttp): E.3.18c wire RuleEngine via RuleEngineTagger
The canonical rule-based engine from §"Tagging engines, layered §1"
of TTP_TAGGING.md was fully implemented but never instantiated as a
composite child — pure pattern rules (R0014/R0017/R0023/... 23 rules
total) had no tagger to dispatch them.
- Add `RuleEngineTagger(Tagger)` adapter in rule_engine.py wrapping
`RuleEngine.evaluate()`. `HANDLES = {command, http_request,
auth_attempt, payload}` — the source kinds whose rules typically
live outside any per-source lifter.
- Adapter's `watch_store()` filters via `_is_engine_owned` so the
engine's dispatch index excludes lifter-claimed rules
(`match.kind: lifter:*`) and stays disjoint from per-lifter ownership.
- Prepend `RuleEngineTagger` to the `CompositeTagger` lifter list so
generic pattern rules dispatch before per-source cross-event logic.
- Composes with E.3.18a (worker hydrates `watch_store`) and E.3.18b
(worker fans session payloads into per-`command` events) — together
these three commits make R0001–R0030 actually fire at runtime.
This commit is contained in:
@@ -142,9 +142,16 @@ def get_tagger() -> Tagger:
|
||||
from decnet.ttp.impl.email_lifter import EmailLifter
|
||||
from decnet.ttp.impl.identity_lifter import IdentityLifter
|
||||
from decnet.ttp.impl.intel_lifter import IntelLifter
|
||||
from decnet.ttp.impl.rule_engine import RuleEngineTagger
|
||||
from decnet.ttp.store.factory import get_rule_store
|
||||
store = get_rule_store()
|
||||
# RuleEngineTagger first so generic pattern rules dispatch
|
||||
# before the per-source lifters' cross-event logic. Order is
|
||||
# observational — every tagger sees every event for its
|
||||
# `HANDLES` set; tags from all of them aggregate into a single
|
||||
# `ttp.tagged` envelope at the worker.
|
||||
return CompositeTagger(lifters=[
|
||||
RuleEngineTagger(store),
|
||||
BehavioralLifter(store),
|
||||
IntelLifter(store),
|
||||
CanaryFingerprintLifter(store),
|
||||
|
||||
@@ -36,7 +36,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from decnet import telemetry as _telemetry
|
||||
from decnet.logging import get_logger
|
||||
from decnet.ttp.base import TaggerEvent
|
||||
from decnet.ttp.base import Tagger, TaggerEvent
|
||||
from decnet.ttp.impl._rule_index import RuleIndex
|
||||
from decnet.ttp.impl._state import apply_ceiling, is_active
|
||||
from decnet.web.db.models.ttp import TTPTag, compute_tag_uuid
|
||||
@@ -344,8 +344,66 @@ def _evaluate_rules(
|
||||
return out
|
||||
|
||||
|
||||
def _is_engine_owned(rule: CompiledRule) -> bool:
|
||||
"""Predicate: rule belongs to the generic RuleEngine, not a lifter.
|
||||
|
||||
Per-source lifters (Behavioral, Intel, …) tag their rules with
|
||||
``match.kind: lifter:<name>_*``. The :class:`RuleEngineTagger`
|
||||
claims everything else — pure ``pattern`` rules whose semantics
|
||||
are "regex against a payload field" with no cross-event state.
|
||||
"""
|
||||
kind = rule.match_spec.get("kind", "")
|
||||
if isinstance(kind, str) and kind.startswith("lifter:"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class RuleEngineTagger(Tagger):
|
||||
"""Tagger adapter that wires :class:`RuleEngine` into the composite.
|
||||
|
||||
The composite tagger fans events out to its children by
|
||||
``HANDLES``; without this adapter the canonical rule-based engine
|
||||
from §"Tagging engines, layered §1" of TTP_TAGGING.md never sees
|
||||
any traffic. This class is intentionally thin — all dispatch and
|
||||
hot-reload logic lives in :class:`RuleEngine` / :class:`RuleIndex`;
|
||||
we only translate between the ``Tagger.tag`` ABC and
|
||||
:meth:`RuleEngine.evaluate`, and route ``watch_store()`` through a
|
||||
predicate that excludes lifter-owned rules so the engine's
|
||||
dispatch index doesn't hold rules another tagger already claims.
|
||||
|
||||
``HANDLES`` enumerates the source kinds whose YAML rules typically
|
||||
live outside any per-source lifter — shell command rules
|
||||
(``command``), HTTP request pattern rules (``http_request``),
|
||||
auth attempts handled by raw regex rather than the
|
||||
:class:`CredentialLifter` cross-event counter, and generic
|
||||
``payload`` matches. The composite uses this for routing; the
|
||||
engine itself filters by ``applies_to`` from the YAML.
|
||||
"""
|
||||
|
||||
name = "rule_engine"
|
||||
HANDLES = frozenset({"command", "http_request", "auth_attempt", "payload"})
|
||||
|
||||
def __init__(self, store: "RuleStore") -> None:
|
||||
self._engine = RuleEngine(store)
|
||||
self._store = store
|
||||
|
||||
async def tag(self, event: TaggerEvent) -> list[TTPTag]:
|
||||
return await self._engine.evaluate(event)
|
||||
|
||||
async def watch_store(self) -> None:
|
||||
# Filter to engine-owned rules so the dispatch index stays
|
||||
# disjoint from per-lifter ownership. Without the predicate
|
||||
# the engine would carry every lifter's rules too — they would
|
||||
# never match (no `pattern` operator), but they would inflate
|
||||
# the index and confuse tooling.
|
||||
await self._engine._index.watch(
|
||||
self._store, predicate=_is_engine_owned,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CompiledRule",
|
||||
"RuleEngine",
|
||||
"RuleEngineTagger",
|
||||
"RuleSchema",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user