Reads pre-shaped session aggregates from TaggerEvent.payload and emits techniques per Appendix A behavior tables. Per-rule predicates dispatch on match.kind (lifter:behavioral_<name>); the lifter holds its own RuleIndex watching the same RuleStore as the engine, so disable / clip / TTL state reaches lifter-bound rules through the same atomic-swap path. R0032/R0036/R0037/R0040 YAMLs had over-escaped regex strings (\\ instead of \\) — fixed in place. Factory wired so default get_tagger() returns CompositeTagger with BehavioralLifter shipped; remaining three lifters (E.3.10-E.3.12) land in subsequent commits. E.2.6 contract preserved via TolerantTagger: empty payload steady-state yields [] with zero ERROR records. Disabled / clipped / expired state verified.
135 lines
4.4 KiB
Python
135 lines
4.4 KiB
Python
"""Contract tests for :mod:`decnet.ttp.factory` (E.1.4).
|
|
|
|
Scoped to the factory + composite dispatch contract: env var routing,
|
|
unknown-name failure, dispatch index correctness, the
|
|
KNOWN_SOURCE_KINDS WARNING/INFO bridge for unhandled events.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from decnet.ttp.base import KNOWN_SOURCE_KINDS, TaggerEvent, TolerantTagger
|
|
from decnet.ttp.factory import CompositeTagger, get_tagger
|
|
from decnet.web.db.models.ttp import TTPTag
|
|
|
|
|
|
def _ev(source_kind: str) -> TaggerEvent:
|
|
return TaggerEvent(
|
|
source_kind=source_kind,
|
|
source_id="src1",
|
|
attacker_uuid=None,
|
|
identity_uuid="id1",
|
|
session_id=None,
|
|
decky_id=None,
|
|
payload={},
|
|
)
|
|
|
|
|
|
def test_default_returns_composite_with_shipped_lifters(monkeypatch):
|
|
"""E.3.9 onward: the default composite is wired with each shipped
|
|
lifter. Empty-lifters was the contract-phase shape; once a lifter
|
|
impl lands the composite carries it.
|
|
"""
|
|
monkeypatch.delenv("DECNET_TTP_TAGGER_TYPE", raising=False)
|
|
t = get_tagger()
|
|
assert isinstance(t, CompositeTagger)
|
|
assert t.name == "composite"
|
|
assert len(t._lifters) >= 1
|
|
|
|
|
|
def test_explicit_composite(monkeypatch):
|
|
monkeypatch.setenv("DECNET_TTP_TAGGER_TYPE", "composite")
|
|
assert isinstance(get_tagger(), CompositeTagger)
|
|
|
|
|
|
def test_unknown_tagger_type_raises(monkeypatch):
|
|
monkeypatch.setenv("DECNET_TTP_TAGGER_TYPE", "nope")
|
|
with pytest.raises(ValueError, match="Unknown tagger"):
|
|
get_tagger()
|
|
|
|
|
|
class _Recorder(TolerantTagger):
|
|
"""Lifter that records calls and returns a single shaped TTPTag."""
|
|
|
|
def __init__(self, name: str, handles: frozenset[str]) -> None:
|
|
self.name = name
|
|
self.HANDLES = handles
|
|
self.calls: list[str] = []
|
|
|
|
async def _tag_impl(self, event: TaggerEvent) -> list[TTPTag]:
|
|
self.calls.append(event.source_kind)
|
|
return []
|
|
|
|
|
|
def test_composite_dispatch_index_is_built_from_handles():
|
|
a = _Recorder("a", frozenset({"command", "email"}))
|
|
b = _Recorder("b", frozenset({"email", "intel"}))
|
|
c = CompositeTagger(lifters=[a, b])
|
|
assert set(c._by_kind["command"]) == {a}
|
|
assert set(c._by_kind["email"]) == {a, b}
|
|
assert set(c._by_kind["intel"]) == {b}
|
|
|
|
|
|
def test_composite_only_invokes_claiming_lifters():
|
|
a = _Recorder("a", frozenset({"command"}))
|
|
b = _Recorder("b", frozenset({"email"}))
|
|
c = CompositeTagger(lifters=[a, b])
|
|
asyncio.run(c.tag(_ev("command")))
|
|
assert a.calls == ["command"]
|
|
assert b.calls == []
|
|
|
|
|
|
def test_composite_unhandled_known_kind_logs_warning_once(caplog):
|
|
c = CompositeTagger(lifters=[])
|
|
# Pick any element of KNOWN_SOURCE_KINDS deterministically.
|
|
known = sorted(KNOWN_SOURCE_KINDS)[0]
|
|
caplog.set_level(logging.INFO, logger="decnet.ttp.factory")
|
|
out1 = asyncio.run(c.tag(_ev(known)))
|
|
out2 = asyncio.run(c.tag(_ev(known)))
|
|
assert out1 == [] and out2 == []
|
|
warnings = [
|
|
r for r in caplog.records
|
|
if r.name == "decnet.ttp.factory" and r.levelno == logging.WARNING
|
|
]
|
|
assert len(warnings) == 1, "expected one WARNING per kind per process"
|
|
|
|
|
|
def test_composite_unhandled_unknown_kind_logs_info_once(caplog):
|
|
c = CompositeTagger(lifters=[])
|
|
unknown = "definitely_not_a_real_source_kind_zzz"
|
|
assert unknown not in KNOWN_SOURCE_KINDS
|
|
caplog.set_level(logging.INFO, logger="decnet.ttp.factory")
|
|
asyncio.run(c.tag(_ev(unknown)))
|
|
asyncio.run(c.tag(_ev(unknown)))
|
|
infos = [
|
|
r for r in caplog.records
|
|
if r.name == "decnet.ttp.factory" and r.levelno == logging.INFO
|
|
]
|
|
warnings = [
|
|
r for r in caplog.records
|
|
if r.name == "decnet.ttp.factory" and r.levelno == logging.WARNING
|
|
]
|
|
assert len(infos) == 1
|
|
assert warnings == []
|
|
|
|
|
|
def test_composite_concatenates_results_from_multiple_lifters():
|
|
class Fixed(TolerantTagger):
|
|
def __init__(self, n: int) -> None:
|
|
self.name = f"fixed{n}"
|
|
self.HANDLES = frozenset({"command"})
|
|
self._n = n
|
|
|
|
async def _tag_impl(self, event):
|
|
# Return a list of the right length without constructing
|
|
# real TTPTag rows — concatenation semantics are what's
|
|
# under test, not row validity.
|
|
return [object()] * self._n
|
|
|
|
c = CompositeTagger(lifters=[Fixed(2), Fixed(3)])
|
|
out = asyncio.run(c.tag(_ev("command")))
|
|
assert len(out) == 5
|