feat(ttp): E.3.9 BehavioralLifter (R0031-R0040)

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.
This commit is contained in:
2026-05-01 20:17:59 -04:00
parent 321ea7a2a6
commit eff3e4bce7
14 changed files with 759 additions and 52 deletions

48
tests/ttp/_stub_store.py Normal file
View File

@@ -0,0 +1,48 @@
"""Shared stub :class:`RuleStore` for lifter unit tests.
Tests that exercise :class:`BehavioralLifter` / :class:`IntelLifter` /
:class:`CanaryFingerprintLifter` / :class:`EmailLifter` need a store
reference at construction. Most don't drive the watch loop — they
inject rules into the lifter's :class:`RuleIndex` directly. This stub
provides just enough of the ABC to satisfy construction.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
class StubRuleStore(RuleStore):
"""In-memory store with optional preloaded compiled rules."""
def __init__(
self,
compiled: list[CompiledRule] | None = None,
changes: list[RuleChange] | None = None,
) -> None:
self._compiled = list(compiled or [])
self._changes = list(changes or [])
async def load_compiled(self) -> list[CompiledRule]:
return list(self._compiled)
async def get_state(self, _rule_id: str) -> RuleState:
return RuleState()
async def set_state(self, *_a: Any, **_kw: Any) -> None:
return None
def subscribe_changes(self) -> AsyncIterator[RuleChange]:
changes = list(self._changes)
async def _gen() -> AsyncIterator[RuleChange]:
for change in changes:
yield change
return _gen()
__all__ = ["StubRuleStore"]

View File

@@ -1,2 +1,11 @@
{"source_kind": "session", "payload": {"beacon_interval_s": 60, "beacon_jitter_pct": 0.05}, "expected_rule_ids": ["R0031"], "label": "low_jitter_beacon"}
{"source_kind": "session", "payload": {"beacon_interval_s": 0, "beacon_jitter_pct": 0}, "expected_rule_ids": [], "label": "negative_no_beacon"}
{"source_kind": "session", "payload": {"command_text": "FLUSHALL", "op_text": "FLUSHALL"}, "expected_rule_ids": ["R0032"], "label": "redis_flushall"}
{"source_kind": "session", "payload": {"body_text": "send 0.5 BTC to 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa to decrypt your files"}, "expected_rule_ids": ["R0033"], "label": "ransom_btc"}
{"source_kind": "session", "payload": {"bytes_out": 5000000, "request_count": 200, "target_host": "exfil.example"}, "expected_rule_ids": ["R0034"], "label": "web_exfil_burst"}
{"source_kind": "session", "payload": {"rows_read": 50000, "bytes_read": 0, "service": "mysql"}, "expected_rule_ids": ["R0035"], "label": "db_mass_read_rows"}
{"source_kind": "http_request", "payload": {"request_path": "/var/www/html/.env"}, "expected_rule_ids": ["R0036"], "label": "creds_env_read"}
{"source_kind": "http_request", "payload": {"request_path": "/api/v1/namespaces/default/secrets"}, "expected_rule_ids": ["R0037"], "label": "k8s_secrets_list"}
{"source_kind": "session", "payload": {"signals": ["privileged:true", "image:nginx"], "container_image": "nginx"}, "expected_rule_ids": ["R0038"], "label": "docker_privileged_create"}
{"source_kind": "session", "payload": {"llmnr_poisoned": true, "victim_host": "client01"}, "expected_rule_ids": ["R0039"], "label": "llmnr_responder"}
{"source_kind": "session", "payload": {"tftp_filename": "router-startup-config", "source_host": "10.0.0.5"}, "expected_rule_ids": ["R0040"], "label": "tftp_router_cfg"}

View File

@@ -1,32 +1,37 @@
"""R0031-R0040 — behavioral / cross-event cohort.
Every rule here is consumed by the BehavioralLifter (or an
identity-rollup variant) at E.3.9. The v0 :class:`RuleEngine` has no
counter / aggregator — it can only regex over a single event
payload — so these rules cannot fire from the engine alone. Their
``match.kind`` keys (``lifter:beaconing`` etc.) are inert to the
regex matcher by design.
Every rule here is consumed by the :class:`BehavioralLifter` (E.3.9).
The v0 :class:`RuleEngine` has no counter / aggregator — it can only
regex over a single event payload — so these rules cannot fire from
the engine alone. Their ``match.kind`` prefix ``lifter:behavioral_``
is inert to the regex matcher by design.
This file asserts:
* every R003N has a YAML on disk that compiles
* the v0 engine NEVER fires any of them (regression guard against a
YAML drifting into a regex match)
* the precision target test is :pyfunc:`pytest.xfail`-gated until
the BehavioralLifter ships, matching the CDD pattern at
``development/TTP_TAGGING.md:2450``.
* the lifter achieves the per-rule precision target on the labelled
corpus.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
import pytest
from decnet.ttp.impl.behavioral_lifter import BehavioralLifter
from decnet.ttp.impl.rule_engine import RuleEngine
from decnet.ttp.store.base import RuleState
from decnet.ttp.store.impl.filesystem import _parse_and_compile
from tests.ttp.rule_precision.conftest import CorpusRow, make_event
from tests.ttp._stub_store import StubRuleStore
from tests.ttp.rule_precision.conftest import (
CorpusRow,
make_event,
precision_for,
)
CohortLoader = Callable[[str], list[CorpusRow]]
@@ -63,15 +68,44 @@ async def test_lifter_bound_inert_in_v0(
)
@pytest.mark.parametrize("rule_id", _RULE_IDS)
@pytest.mark.xfail(strict=True, reason="impl phase E.3.9 (BehavioralLifter)")
def test_behavioral_rule_precision(rule_id: str) -> None:
"""Will live once the BehavioralLifter ships at E.3.9.
def _all_rule_ids() -> list[str]:
return _RULE_IDS
The lifter consumes ``AttackerBehavior`` / session aggregates and
emits one tag per matching rule_id. This test will then load the
behavioral corpus, drive the lifter, and assert the per-rule
precision target. Until that day this xfails strict so the suite
flips green automatically when E.3.9 wires it up.
def _build_lifter() -> BehavioralLifter:
rules_dir = Path("rules/ttp")
rules = [
_parse_and_compile(rules_dir / f"{rid}.yaml", RuleState())
for rid in _all_rule_ids()
]
lifter = BehavioralLifter(StubRuleStore(compiled=rules))
for rule in rules:
lifter._index.install(rule)
return lifter
@pytest.mark.parametrize("rule_id", _RULE_IDS)
def test_behavioral_rule_precision(
rule_id: str,
corpus_loader: CohortLoader,
) -> None:
"""Drive the lifter over the behavioral corpus and assert precision.
H-band (≥0.85 confidence) → ≥95% precision. v0 ships with a small
synthetic seed corpus; precision_for() returns 1.0 when no rows
match, so the assertion exercises the FP-guard rather than the
recall property (recall is intentionally not a v1 target — see
TTP_TAGGING.md Appendix C).
"""
pytest.fail(f"{rule_id}: BehavioralLifter not yet shipped (E.3.9)")
rows = corpus_loader("behavioral")
if not rows:
pytest.skip("no behavioral corpus available")
lifter = _build_lifter()
fired: dict[str, list[str]] = {}
for row in rows:
tags = asyncio.run(lifter.tag(make_event(row)))
fired[row.label] = [tag.rule_id for tag in tags]
precision, _tp, _fp = precision_for(rule_id, rows, fired)
assert precision >= 0.95, (
f"{rule_id} precision {precision:.2f} < 0.95 on behavioral corpus"
)

View File

@@ -0,0 +1,267 @@
"""Per-rule unit tests for :class:`BehavioralLifter` (E.3.9).
Each R003N gets a positive payload that fires the predicate and a
negative payload that does not. State modulation is tested once
(disable / clip) since it's funneled through the shared
:func:`is_active` / :func:`apply_ceiling` helpers.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import pytest
from decnet.ttp.base import TaggerEvent
from decnet.ttp.impl.behavioral_lifter import BehavioralLifter
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleState
from decnet.ttp.store.impl.filesystem import _parse_and_compile
from tests.ttp._stub_store import StubRuleStore
_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp"
def _compile(rule_id: str, state: RuleState | None = None) -> CompiledRule:
return _parse_and_compile(
_RULES_DIR / f"{rule_id}.yaml", state or RuleState(),
)
def _ev(source_kind: str, payload: dict[str, Any]) -> TaggerEvent:
return TaggerEvent(
source_kind=source_kind,
source_id=f"src-{source_kind}",
attacker_uuid="att1",
identity_uuid=None,
session_id="sess1",
decky_id=None,
payload=payload,
)
def _make_lifter_with(rule_ids: list[str]) -> BehavioralLifter:
rules = [_compile(rid) for rid in rule_ids]
lifter = BehavioralLifter(StubRuleStore(compiled=rules))
for rule in rules:
lifter._index.install(rule)
return lifter
# ── Per-rule positive cases ─────────────────────────────────────────
@pytest.mark.parametrize(
"rule_id,source_kind,payload,techniques",
[
(
"R0031",
"session",
{"beacon_interval_s": 60, "beacon_jitter_pct": 0.05},
{"T1071", "T1029"},
),
(
"R0032",
"session",
{"command_text": "FLUSHALL", "op_text": "FLUSHALL"},
{"T1485"},
),
(
"R0033",
"session",
{"body_text": "Send 0.5 BTC to 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa to decrypt"},
{"T1486"},
),
(
"R0034",
"session",
{"bytes_out": 5_000_000, "request_count": 100},
{"T1567"},
),
(
"R0035",
"session",
{"rows_read": 50_000, "bytes_read": 1_000},
{"T1213"},
),
(
"R0036",
"http_request",
{"request_path": "/var/www/.env"},
{"T1552"},
),
(
"R0037",
"http_request",
{"request_path": "/api/v1/namespaces/default/secrets"},
{"T1552"},
),
(
"R0038",
"session",
{"signals": ["privileged:true", "image:nginx"]},
{"T1611"},
),
(
"R0039",
"session",
{"llmnr_poisoned": True},
{"T1557"},
),
(
"R0040",
"session",
{"tftp_filename": "router-startup-config"},
{"T1602"},
),
],
)
def test_rule_fires_on_positive_payload(
rule_id: str,
source_kind: str,
payload: dict[str, Any],
techniques: set[str],
) -> None:
lifter = _make_lifter_with([rule_id])
out = asyncio.run(lifter.tag(_ev(source_kind, payload)))
assert out, f"{rule_id} did not fire on its positive payload"
fired = {tag.technique_id for tag in out}
assert fired == techniques
for tag in out:
assert tag.rule_id == rule_id
assert tag.attacker_uuid == "att1"
# ── Negative cases ──────────────────────────────────────────────────
def test_beaconing_rejects_high_jitter() -> None:
lifter = _make_lifter_with(["R0031"])
out = asyncio.run(lifter.tag(
_ev("session", {"beacon_interval_s": 60, "beacon_jitter_pct": 0.5}),
))
assert out == []
def test_beaconing_rejects_short_interval() -> None:
lifter = _make_lifter_with(["R0031"])
out = asyncio.run(lifter.tag(
_ev("session", {"beacon_interval_s": 2, "beacon_jitter_pct": 0.05}),
))
assert out == []
def test_data_destruction_rejects_unrelated_text() -> None:
lifter = _make_lifter_with(["R0032"])
out = asyncio.run(lifter.tag(
_ev("session", {"command_text": "SELECT 1"}),
))
assert out == []
def test_ransom_note_requires_btc_or_xmr_when_flagged() -> None:
lifter = _make_lifter_with(["R0033"])
# has keyword but no address
out = asyncio.run(lifter.tag(
_ev("session", {"body_text": "send bitcoin to decrypt"}),
))
assert out == []
def test_exfil_below_thresholds_no_fire() -> None:
lifter = _make_lifter_with(["R0034"])
out = asyncio.run(lifter.tag(
_ev("session", {"bytes_out": 100, "request_count": 1}),
))
assert out == []
def test_path_match_rules_skip_unrelated_paths() -> None:
lifter = _make_lifter_with(["R0036", "R0037"])
out = asyncio.run(lifter.tag(
_ev("http_request", {"request_path": "/index.html"}),
))
assert out == []
def test_event_source_kind_outside_applies_to_no_fire() -> None:
"""A behavioral rule with applies_to=[session] must not fire on
an http_request event even if the predicate would otherwise pass.
"""
lifter = _make_lifter_with(["R0031"])
out = asyncio.run(lifter.tag(
_ev("http_request", {"beacon_interval_s": 60, "beacon_jitter_pct": 0.05}),
))
assert out == []
# ── State modulation ────────────────────────────────────────────────
def test_disabled_state_skips_emit() -> None:
rule = _compile("R0031", RuleState(state="disabled"))
lifter = BehavioralLifter(StubRuleStore())
lifter._index.install(rule)
out = asyncio.run(lifter.tag(
_ev("session", {"beacon_interval_s": 60, "beacon_jitter_pct": 0.05}),
))
assert out == []
def test_clipped_state_caps_confidence() -> None:
rule = _compile("R0031", RuleState(state="clipped", confidence_max=0.5))
lifter = BehavioralLifter(StubRuleStore())
lifter._index.install(rule)
out = asyncio.run(lifter.tag(
_ev("session", {"beacon_interval_s": 60, "beacon_jitter_pct": 0.05}),
))
# Base confidences in YAML are 0.8 and 0.85; clipped to 0.5 ceiling
# → 0.4 and 0.425 respectively.
assert out
for tag in out:
assert tag.confidence < 0.5
def test_expired_state_treated_as_disabled() -> None:
rule = _compile(
"R0031",
RuleState(
state="enabled",
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
),
)
lifter = BehavioralLifter(StubRuleStore())
lifter._index.install(rule)
out = asyncio.run(lifter.tag(
_ev("session", {"beacon_interval_s": 60, "beacon_jitter_pct": 0.05}),
))
assert out == []
# ── Ownership / hot-reload via watch_store hydration ────────────────
def test_owns_only_behavioral_prefix() -> None:
intel = _compile("R0054") # match.kind = lifter:intel_abuseipdb
behavioral = _compile("R0031")
lifter = BehavioralLifter(
StubRuleStore(compiled=[intel, behavioral]),
)
asyncio.run(lifter._index.hydrate_from(
lifter._store, predicate=lifter._owns, # type: ignore[arg-type]
))
assert lifter._index.get("R0031") is not None
assert lifter._index.get("R0054") is None
def test_tolerates_absent_payload(caplog: pytest.LogCaptureFixture) -> None:
"""The empty payload steady-state must not produce ERROR records."""
caplog.set_level(logging.DEBUG)
lifter = _make_lifter_with(["R0031", "R0032", "R0036"])
out = asyncio.run(lifter.tag(_ev("session", {})))
assert out == []
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]

View File

@@ -28,12 +28,16 @@ def _ev(source_kind: str) -> TaggerEvent:
)
def test_default_returns_composite_with_empty_lifters(monkeypatch):
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 t._lifters == []
assert len(t._lifters) >= 1
def test_explicit_composite(monkeypatch):

View File

@@ -33,6 +33,18 @@ from decnet.ttp.impl.credential_lifter import CredentialLifter
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 tests.ttp._stub_store import StubRuleStore
def _make_lifter(cls: type[TolerantTagger]) -> TolerantTagger:
"""Construct a lifter with whatever its current signature wants.
Implemented lifters (E.3.9E.3.12) take a :class:`RuleStore`; the
still-empty IdentityLifter / CredentialLifter (E.3.13) take no args.
"""
if cls is BehavioralLifter:
return cls(StubRuleStore()) # type: ignore[call-arg]
return cls()
def _ev(source_kind: str, payload: dict[str, Any] | None = None) -> TaggerEvent:
@@ -77,7 +89,7 @@ def test_lifter_tolerates_absence(
) -> None:
caplog.clear()
caplog.set_level(logging.DEBUG)
lifter = lifter_cls()
lifter = _make_lifter(lifter_cls)
out = asyncio.run(lifter.tag(_ev(source_kind, payload)))
assert out == []
# The load-bearing property: no ERROR-or-above records. WARNING

View File

@@ -20,6 +20,13 @@ from decnet.ttp.impl.credential_lifter import CredentialLifter
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 tests.ttp._stub_store import StubRuleStore
def _instantiate(cls: type[TolerantTagger]) -> TolerantTagger:
if cls is BehavioralLifter:
return cls(StubRuleStore()) # type: ignore[call-arg]
return cls()
ALL_LIFTERS = [
BehavioralLifter,
@@ -65,7 +72,7 @@ def test_lifter_names_are_unique_and_non_empty():
@pytest.mark.parametrize("cls", ALL_LIFTERS)
def test_lifter_tag_returns_empty_list_for_handled_event(cls):
lifter = cls()
lifter = _instantiate(cls)
kind = next(iter(cls.HANDLES))
out = asyncio.run(lifter.tag(_ev(kind)))
assert out == []
@@ -74,7 +81,7 @@ def test_lifter_tag_returns_empty_list_for_handled_event(cls):
@pytest.mark.parametrize("cls", ALL_LIFTERS)
def test_lifter_instantiable(cls):
# No abstract methods left — concrete subclass must be constructible.
cls()
_instantiate(cls)
# ── E.2.6 deferred absence-tolerance behavior ──────────────────────
@@ -85,6 +92,10 @@ def test_e26_intel_lifter_partial_provider_nulls():
raise AssertionError("not yet implemented")
@pytest.mark.xfail(strict=True, reason="impl phase E.3 — BehavioralLifter empty join")
def test_e26_behavioral_lifter_no_attacker_behavior_row():
raise AssertionError("not yet implemented")
"""E.3.9: a session event with no AttackerBehavior fields populated
must produce zero tags and zero errors. Was xfail-strict before
BehavioralLifter shipped; now a real assertion."""
lifter = BehavioralLifter(StubRuleStore())
out = asyncio.run(lifter.tag(_ev("session")))
assert out == []