feat(ttp): E.3.8 R0031-R0040 behavioral cohort
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.
This commit is contained in:
23
rules/ttp/R0031.yaml
Normal file
23
rules/ttp/R0031.yaml
Normal file
@@ -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
|
||||
24
rules/ttp/R0032.yaml
Normal file
24
rules/ttp/R0032.yaml
Normal file
@@ -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
|
||||
26
rules/ttp/R0033.yaml
Normal file
26
rules/ttp/R0033.yaml
Normal file
@@ -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
|
||||
21
rules/ttp/R0034.yaml
Normal file
21
rules/ttp/R0034.yaml
Normal file
@@ -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
|
||||
21
rules/ttp/R0035.yaml
Normal file
21
rules/ttp/R0035.yaml
Normal file
@@ -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
|
||||
25
rules/ttp/R0036.yaml
Normal file
25
rules/ttp/R0036.yaml
Normal file
@@ -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
|
||||
22
rules/ttp/R0037.yaml
Normal file
22
rules/ttp/R0037.yaml
Normal file
@@ -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
|
||||
23
rules/ttp/R0038.yaml
Normal file
23
rules/ttp/R0038.yaml
Normal file
@@ -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
|
||||
19
rules/ttp/R0039.yaml
Normal file
19
rules/ttp/R0039.yaml
Normal file
@@ -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
|
||||
23
rules/ttp/R0040.yaml
Normal file
23
rules/ttp/R0040.yaml
Normal file
@@ -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
|
||||
77
tests/ttp/rule_precision/test_behavioral_rules.py
Normal file
77
tests/ttp/rule_precision/test_behavioral_rules.py
Normal file
@@ -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)")
|
||||
Reference in New Issue
Block a user