From 806301e179a57f0a993f349f5a2d79e314b1d3c8 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 09:18:27 -0400 Subject: [PATCH] feat(ttp): E.3.8 R0031-R0040 behavioral cohort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 YAMLs for the behavioral / cross-event cohort per Appendix B: beaconing, data destruction, ransom note, web exfil, DB mass-read, credentials-in-files, k8s SA token harvest, Docker host escape, LLMNR poisoning, TFTP router-config retrieval. Every rule is lifter-bound (BehavioralLifter / IdentityLifter) — the v0 RuleEngine cannot count, aggregate, or compose cross-event signals, so these YAMLs declare the technique mappings the lifter will consume by rule_id at E.3.9. Their match specs use a 'kind: lifter:*' shape inert to the regex matcher. test_behavioral_rules.py asserts each YAML compiles, none fire from the v0 engine (FP regression guard against a YAML drifting into a regex), and an xfail(strict=True, reason='impl phase E.3.9') precision case that will flip green when the lifter lands. --- rules/ttp/R0031.yaml | 23 ++++++ rules/ttp/R0032.yaml | 24 ++++++ rules/ttp/R0033.yaml | 26 +++++++ rules/ttp/R0034.yaml | 21 +++++ rules/ttp/R0035.yaml | 21 +++++ rules/ttp/R0036.yaml | 25 ++++++ rules/ttp/R0037.yaml | 22 ++++++ rules/ttp/R0038.yaml | 23 ++++++ rules/ttp/R0039.yaml | 19 +++++ rules/ttp/R0040.yaml | 23 ++++++ .../rule_precision/test_behavioral_rules.py | 77 +++++++++++++++++++ 11 files changed, 304 insertions(+) create mode 100644 rules/ttp/R0031.yaml create mode 100644 rules/ttp/R0032.yaml create mode 100644 rules/ttp/R0033.yaml create mode 100644 rules/ttp/R0034.yaml create mode 100644 rules/ttp/R0035.yaml create mode 100644 rules/ttp/R0036.yaml create mode 100644 rules/ttp/R0037.yaml create mode 100644 rules/ttp/R0038.yaml create mode 100644 rules/ttp/R0039.yaml create mode 100644 rules/ttp/R0040.yaml create mode 100644 tests/ttp/rule_precision/test_behavioral_rules.py diff --git a/rules/ttp/R0031.yaml b/rules/ttp/R0031.yaml new file mode 100644 index 00000000..14b184b0 --- /dev/null +++ b/rules/ttp/R0031.yaml @@ -0,0 +1,23 @@ +rule_id: R0031 +rule_version: 1 +name: beaconing +description: | + Periodic outbound activity with low jitter — classic C2 beacon. + Read from AttackerBehavior.beacon_interval_s / .beacon_jitter_pct + by the BehavioralLifter (E.3.9). +applies_to: + - session +match: + kind: lifter:beaconing + max_jitter_pct: 0.15 + min_interval_s: 10 +emits: + - tactic: TA0011 + technique_id: T1071 + confidence: 0.8 + - tactic: TA0011 + technique_id: T1029 + confidence: 0.85 +evidence_fields: + - beacon_interval_s + - beacon_jitter_pct diff --git a/rules/ttp/R0032.yaml b/rules/ttp/R0032.yaml new file mode 100644 index 00000000..127990d2 --- /dev/null +++ b/rules/ttp/R0032.yaml @@ -0,0 +1,24 @@ +rule_id: R0032 +rule_version: 1 +name: data_destruction +description: | + Mass destructive ops: Redis FLUSHALL, SQL DROP DATABASE, MongoDB + dropDatabase(), bulk DELETE without WHERE. Cross-event because we + want to confirm the verb landed on real data, not just a parse. +applies_to: + - session +match: + kind: lifter:data_destruction + patterns: + - 'FLUSHALL' + - 'DROP\\s+DATABASE' + - 'TRUNCATE\\s+TABLE' + - 'dropDatabase\\(\\)' + - 'DELETE\\s+/\\_all' +emits: + - tactic: TA0040 + technique_id: T1485 + confidence: 0.95 +evidence_fields: + - matched_op + - target diff --git a/rules/ttp/R0033.yaml b/rules/ttp/R0033.yaml new file mode 100644 index 00000000..ebb99f16 --- /dev/null +++ b/rules/ttp/R0033.yaml @@ -0,0 +1,26 @@ +rule_id: R0033 +rule_version: 1 +name: ransom_note_pattern +description: | + Bitcoin/Monero address + payment-demand language inserted into a + honeydoc, mail body, or DB collection. EmailLifter (R0033 fires + from email_body too) and BehavioralLifter share the same rule_id. +applies_to: + - session + - email +match: + kind: lifter:ransom_note + require_btc_or_xmr: true + payment_keywords: + - bitcoin + - btc + - monero + - ransom + - decrypt +emits: + - tactic: TA0040 + technique_id: T1486 + confidence: 0.9 +evidence_fields: + - btc_address + - matched_keywords diff --git a/rules/ttp/R0034.yaml b/rules/ttp/R0034.yaml new file mode 100644 index 00000000..9516cfa5 --- /dev/null +++ b/rules/ttp/R0034.yaml @@ -0,0 +1,21 @@ +rule_id: R0034 +rule_version: 1 +name: exfil_over_web +description: | + Outbound data exfil to a web service (POST with large body / + Content-Length, or many GETs encoding payload in path). Read + from session aggregates, not single requests. +applies_to: + - session +match: + kind: lifter:exfil_over_web + min_payload_bytes: 1048576 + request_threshold: 50 +emits: + - tactic: TA0010 + technique_id: T1567 + confidence: 0.85 +evidence_fields: + - bytes_out + - request_count + - target_host diff --git a/rules/ttp/R0035.yaml b/rules/ttp/R0035.yaml new file mode 100644 index 00000000..7728711d --- /dev/null +++ b/rules/ttp/R0035.yaml @@ -0,0 +1,21 @@ +rule_id: R0035 +rule_version: 1 +name: db_mass_read +description: | + SELECT/COPY/RETR pulling many rows or whole tables from a DB + honeypot. BehavioralLifter (E.3.9) reads the per-session + query-byte aggregate. +applies_to: + - session +match: + kind: lifter:db_mass_read + min_rows: 10000 + min_bytes: 5242880 +emits: + - tactic: TA0009 + technique_id: T1213 + confidence: 0.85 +evidence_fields: + - service + - rows_read + - bytes_read diff --git a/rules/ttp/R0036.yaml b/rules/ttp/R0036.yaml new file mode 100644 index 00000000..7b831291 --- /dev/null +++ b/rules/ttp/R0036.yaml @@ -0,0 +1,25 @@ +rule_id: R0036 +rule_version: 1 +name: credentials_in_files +description: | + Reading .env / .git/config / cloud credential files — + credential-from-file harvesting. Lifter-driven so the rule + composes the file-access signal with the path discriminator. +applies_to: + - session + - http_request +match: + kind: lifter:credentials_in_files + paths: + - '\\.env' + - '\\.git/config' + - '\\.aws/credentials' + - '\\.ssh/id_rsa' + - 'wp-config\\.php' +emits: + - tactic: TA0006 + technique_id: T1552 + sub_technique_id: T1552.001 + confidence: 0.9 +evidence_fields: + - matched_path diff --git a/rules/ttp/R0037.yaml b/rules/ttp/R0037.yaml new file mode 100644 index 00000000..ef7c9312 --- /dev/null +++ b/rules/ttp/R0037.yaml @@ -0,0 +1,22 @@ +rule_id: R0037 +rule_version: 1 +name: k8s_service_account_token +description: | + Reading /api/v1/namespaces/*/secrets or /var/run/secrets/k8s.io/ + serviceaccount/token — kube SA harvest. +applies_to: + - session + - http_request +match: + kind: lifter:k8s_sa_token + paths: + - '/api/v1/namespaces/[^/]+/secrets' + - '/var/run/secrets/kubernetes\\.io/serviceaccount' +emits: + - tactic: TA0006 + technique_id: T1552 + sub_technique_id: T1552.007 + confidence: 0.95 +evidence_fields: + - matched_path + - namespace diff --git a/rules/ttp/R0038.yaml b/rules/ttp/R0038.yaml new file mode 100644 index 00000000..3b0404d3 --- /dev/null +++ b/rules/ttp/R0038.yaml @@ -0,0 +1,23 @@ +rule_id: R0038 +rule_version: 1 +name: docker_host_escape +description: | + Privileged container creation or bind-mount of host /, /etc, or + /var/run/docker.sock — host-escape primitive. Lifter inspects + the structured Docker-API event payload. +applies_to: + - session +match: + kind: lifter:docker_escape + signals: + - 'privileged:true' + - 'bind:/:/' + - 'bind:/etc' + - 'bind:/var/run/docker.sock' +emits: + - tactic: TA0004 + technique_id: T1611 + confidence: 0.95 +evidence_fields: + - matched_signal + - container_image diff --git a/rules/ttp/R0039.yaml b/rules/ttp/R0039.yaml new file mode 100644 index 00000000..8b7c6cc1 --- /dev/null +++ b/rules/ttp/R0039.yaml @@ -0,0 +1,19 @@ +rule_id: R0039 +rule_version: 1 +name: llmnr_poisoning +description: | + Responder-style LLMNR/NBT-NS spoofed reply pattern observed + on the network sniffer. BehavioralLifter (E.3.9) reads the + network-event aggregate. +applies_to: + - session +match: + kind: lifter:llmnr_poisoning +emits: + - tactic: TA0009 + technique_id: T1557 + sub_technique_id: T1557.001 + confidence: 0.9 +evidence_fields: + - poisoned_query + - victim_host diff --git a/rules/ttp/R0040.yaml b/rules/ttp/R0040.yaml new file mode 100644 index 00000000..100b5ec8 --- /dev/null +++ b/rules/ttp/R0040.yaml @@ -0,0 +1,23 @@ +rule_id: R0040 +rule_version: 1 +name: tftp_router_config_retrieval +description: | + TFTP RRQ for a router-config-shaped filename (*-confg, *.cfg, + startup-config, running-config). Per Appendix A.4. +applies_to: + - session +match: + kind: lifter:tftp_router_config + filename_patterns: + - '.*-confg$' + - '.*\\.cfg$' + - 'startup-config' + - 'running-config' +emits: + - tactic: TA0009 + technique_id: T1602 + sub_technique_id: T1602.002 + confidence: 0.9 +evidence_fields: + - tftp_filename + - source_host diff --git a/tests/ttp/rule_precision/test_behavioral_rules.py b/tests/ttp/rule_precision/test_behavioral_rules.py new file mode 100644 index 00000000..1ce6cb24 --- /dev/null +++ b/tests/ttp/rule_precision/test_behavioral_rules.py @@ -0,0 +1,77 @@ +"""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. + +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``. +""" +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +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 + +CohortLoader = Callable[[str], list[CorpusRow]] + +_RULE_IDS = [f"R{n:04d}" for n in range(31, 41)] + + +@pytest.mark.parametrize("rule_id", _RULE_IDS) +def test_rule_yaml_present(rule_id: str) -> None: + path = Path("rules/ttp") / f"{rule_id}.yaml" + assert path.exists(), f"missing YAML: {path}" + compiled = _parse_and_compile(path, RuleState()) + assert compiled.rule_id == rule_id + + +@pytest.mark.parametrize("rule_id", _RULE_IDS) +async def test_lifter_bound_inert_in_v0( + rule_id: str, + precision_engine: RuleEngine, + corpus_loader: CohortLoader, +) -> None: + """Behavioral rules MUST NOT fire from the regex engine. + + Walks both the behavioral and the command corpora — if any event + in either set lights up a behavioral rule, a YAML drifted into a + regex match.spec. + """ + fired: set[str] = set() + for cohort in ("behavioral", "commands"): + for row in corpus_loader(cohort): + tags = await precision_engine.evaluate(make_event(row)) + fired.update(tag.rule_id for tag in tags) + assert rule_id not in fired, ( + f"{rule_id} is lifter-bound but fired from the regex engine" + ) + + +@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. + + 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. + """ + pytest.fail(f"{rule_id}: BehavioralLifter not yet shipped (E.3.9)")