feat(ttp): E.3.8 R0001-R0030 command cohort

30 YAMLs for the shell/command rule cohort per Appendix B (rules/ttp/).
Splits into engine-active (R0007-R0029, regex on command_text /
raw_url / user_agent) and lifter-bound (R0001-R0006, R0030 — the
v0 RuleEngine cannot count auth attempts, do identity rollups, or
parse fingerprint blobs; the BehavioralLifter / IdentityLifter /
CredentialLifter consume them by rule_id at E.3.9 / E.3.13).

test_command_rules.py asserts:
- every R000N has a YAML that compiles
- lifter-bound rules NEVER fire from the v0 engine (regression
  guard against a YAML drifting into a regex match.spec)
- engine-active rules meet their Appendix-C precision target
  against the seed corpus (≥0.95 high-conf, ≥0.80 medium)

Conftest fixes: precision_engine moved to module-scope so module-
scope precomputed dispatch fixture (fired_by_label) can request it;
_RULES_DIR path bumped from parents[2] to parents[3] so the loader
resolves the project root regardless of pytest cwd; make_event
synthesizes attacker_uuid so TTPTag's anchor invariant is satisfied.

Seed corpus broadened: positive examples for every regex rule plus
6 negative examples across innocuous shell verbs (ls, echo, cd, ps,
df, free) so FPs surface in precision rather than passing vacuously.
This commit is contained in:
2026-05-01 09:16:38 -04:00
parent c635478442
commit b1fe1f9403
33 changed files with 758 additions and 15 deletions

View File

@@ -32,7 +32,7 @@ from decnet.ttp.impl.rule_engine import CompiledRule, RuleEngine
from decnet.ttp.store.base import RuleState
from decnet.ttp.store.impl.filesystem import _parse_and_compile
_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp"
_RULES_DIR = Path(__file__).resolve().parents[3] / "rules" / "ttp"
_CORPUS_DIR = Path(__file__).resolve().parent / "corpus"
@@ -102,7 +102,7 @@ def compiled_rules() -> list[CompiledRule]:
return _load_compiled_rules()
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="module")
async def precision_engine(
compiled_rules: list[CompiledRule],
) -> RuleEngine:
@@ -167,11 +167,19 @@ def corpus_loader() -> Callable[[str], list[CorpusRow]]:
def make_event(row: CorpusRow, source_id: str = "src") -> TaggerEvent:
"""Materialise a :class:`CorpusRow` into a :class:`TaggerEvent`."""
"""Materialise a :class:`CorpusRow` into a :class:`TaggerEvent`.
Sets a deterministic ``attacker_uuid`` derived from the row label so
the downstream ``TTPTag`` constructor's "at least one of
attacker_uuid/identity_uuid" invariant is satisfied. The corpus
rows themselves don't carry attacker identity — they're per-payload
fixtures, not per-attacker — so this synthesis is purely a test
plumbing concern.
"""
return TaggerEvent(
source_kind=row.source_kind,
source_id=source_id,
attacker_uuid=None,
attacker_uuid=f"corpus-{row.label}",
identity_uuid=None,
session_id=None,
decky_id=None,