From b819dfefa3f4382b90a321ccc623d7eed5f5038b Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 09:22:48 -0400 Subject: [PATCH] feat(ttp): E.3.8 R0054-R0058 intel cohort + mark step done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 YAMLs for the intel-verdict cohort per Appendix B / A.10: AbuseIPDB category mapping, GreyNoise classification, Feodo Tracker hit, ThreatFox IOC type, aggregate-malicious bump-only. IntelLifter (E.3.10) consumes by rule_id and tolerates absence silently (null provider column → no tag). R0058 is the meta bump-only rule — emits a single confidence=0.0 sentinel so it validates and surfaces in the catalogue, but the repository's sub-0.3 drop ensures no fresh tag persists if the fanout fires accidentally. test_intel_rules.py pins that zero-confidence invariant. Marks E.3.8 done in development/TTP_TAGGING.md with the cohort- split summary. --- development/TTP_TAGGING.md | 15 +++- rules/ttp/R0054.yaml | 25 +++++++ rules/ttp/R0055.yaml | 23 ++++++ rules/ttp/R0056.yaml | 23 ++++++ rules/ttp/R0057.yaml | 23 ++++++ rules/ttp/R0058.yaml | 23 ++++++ tests/ttp/rule_precision/test_intel_rules.py | 73 ++++++++++++++++++++ 7 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 rules/ttp/R0054.yaml create mode 100644 rules/ttp/R0055.yaml create mode 100644 rules/ttp/R0056.yaml create mode 100644 rules/ttp/R0057.yaml create mode 100644 rules/ttp/R0058.yaml create mode 100644 tests/ttp/rule_precision/test_intel_rules.py diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index 33126ab1..60bec031 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -2969,7 +2969,20 @@ Order: test per Appendix C in the same commit. The corpus for precision testing comes from a labelled holdout fixture under `tests/ttp/rule_precision/corpus/` — that fixture is itself a - sub-step (commit) before any rule lands. + sub-step (commit) before any rule lands. ✅ done. Cohorts shipped + in 6 commits: corpus+harness, then command (R0001–R0030), + behavioral (R0031–R0040), email (R0041–R0048), canary + (R0049–R0053), intel (R0054–R0058). Live precision asserts on + R0007–R0029 (regex-on-payload-field). Lifter-bound rules + (R0001–R0006, R0030, R0031–R0058) are inert under the v0 engine + by design — their YAMLs declare technique mappings the + E.3.9–E.3.13 lifters consume by `rule_id`, with each precision + case `xfail(strict=True)`-gated on the matching lifter step. + R0058 emits at confidence 0.0 (bump-only meta-rule; repository + drops sub-0.3 anyway). The corpus extractor lives at + `tests/ttp/rule_precision/_build_corpus.py`; operator-built + corpora are gitignored, only synthetic `seed_*.jsonl` is + committed. 9. **BehavioralLifter** — read `AttackerBehavior` / `Credential` / `CredentialReuse`, emit per Appendix A behavior tables. Tests in `test_lifter_absence.py` and a new diff --git a/rules/ttp/R0054.yaml b/rules/ttp/R0054.yaml new file mode 100644 index 00000000..abf26413 --- /dev/null +++ b/rules/ttp/R0054.yaml @@ -0,0 +1,25 @@ +rule_id: R0054 +rule_version: 1 +name: abuseipdb_category +description: | + AbuseIPDB category → ATT&CK technique mapping per Appendix A.10. + IntelLifter reads AttackerIntel.abuseipdb_categories and emits + one tag per matching category code. +applies_to: + - intel +match: + kind: lifter:intel_abuseipdb + provider: abuseipdb +emits: + - tactic: TA0006 + technique_id: T1110 + confidence: 0.7 + - tactic: TA0001 + technique_id: T1190 + confidence: 0.7 + - tactic: TA0001 + technique_id: T1566 + confidence: 0.7 +evidence_fields: + - abuseipdb_categories + - abuse_confidence_score diff --git a/rules/ttp/R0055.yaml b/rules/ttp/R0055.yaml new file mode 100644 index 00000000..83feacf1 --- /dev/null +++ b/rules/ttp/R0055.yaml @@ -0,0 +1,23 @@ +rule_id: R0055 +rule_version: 1 +name: greynoise_classification +description: | + GreyNoise classification + tag → ATT&CK technique per A.10. + IntelLifter reads AttackerIntel.greynoise_classification and + greynoise_tags. +applies_to: + - intel +match: + kind: lifter:intel_greynoise + provider: greynoise +emits: + - tactic: TA0043 + technique_id: T1595 + sub_technique_id: T1595.002 + confidence: 0.7 + - tactic: TA0011 + technique_id: T1071 + confidence: 0.7 +evidence_fields: + - greynoise_classification + - greynoise_tags diff --git a/rules/ttp/R0056.yaml b/rules/ttp/R0056.yaml new file mode 100644 index 00000000..09f5b057 --- /dev/null +++ b/rules/ttp/R0056.yaml @@ -0,0 +1,23 @@ +rule_id: R0056 +rule_version: 1 +name: feodo_tracker_hit +description: | + Source IP listed by abuse.ch Feodo Tracker — known C2 infra, + family attribution attached. +applies_to: + - intel +match: + kind: lifter:intel_feodo + provider: feodo +emits: + - tactic: TA0011 + technique_id: T1071 + sub_technique_id: T1071.001 + confidence: 0.85 + - tactic: TA0042 + technique_id: T1588 + sub_technique_id: T1588.001 + confidence: 0.85 +evidence_fields: + - malware_family + - first_seen_feodo diff --git a/rules/ttp/R0057.yaml b/rules/ttp/R0057.yaml new file mode 100644 index 00000000..d9fd2cef --- /dev/null +++ b/rules/ttp/R0057.yaml @@ -0,0 +1,23 @@ +rule_id: R0057 +rule_version: 1 +name: threatfox_ioc +description: | + abuse.ch ThreatFox IOC type → ATT&CK technique mapping with + family attribution. +applies_to: + - intel +match: + kind: lifter:intel_threatfox + provider: threatfox +emits: + - tactic: TA0011 + technique_id: T1071 + confidence: 0.8 + - tactic: TA0042 + technique_id: T1588 + sub_technique_id: T1588.001 + confidence: 0.8 +evidence_fields: + - ioc_type + - malware_family + - threat_type diff --git a/rules/ttp/R0058.yaml b/rules/ttp/R0058.yaml new file mode 100644 index 00000000..b73fa121 --- /dev/null +++ b/rules/ttp/R0058.yaml @@ -0,0 +1,23 @@ +rule_id: R0058 +rule_version: 1 +name: aggregate_malicious_verdict_bump +description: | + Aggregate intel verdict = "malicious" with no specific provider + mapping. Per Appendix B: confidence-bump existing tags only, + never emits a fresh tag. emits is intentionally a single + zero-confidence sentinel so the rule still validates and the + catalogue surfaces it; the IntelLifter inspects rule_id and + bumps existing tags' confidence rather than calling the engine + fanout. +applies_to: + - intel +match: + kind: lifter:intel_aggregate_bump + bump_amount: 0.05 +emits: + - tactic: TA0042 + technique_id: T1588 + confidence: 0.0 +evidence_fields: + - aggregate_verdict + - bumped_rule_ids diff --git a/tests/ttp/rule_precision/test_intel_rules.py b/tests/ttp/rule_precision/test_intel_rules.py new file mode 100644 index 00000000..b84b864d --- /dev/null +++ b/tests/ttp/rule_precision/test_intel_rules.py @@ -0,0 +1,73 @@ +"""R0054-R0058 — intel verdict cohort. + +IntelLifter (E.3.10) reads ``AttackerIntel`` provider columns +(AbuseIPDB, GreyNoise, Feodo, ThreatFox) and emits per the per- +provider mapping tables in Appendix A.10. Per Appendix B every +intel rule tolerates absence silently — a null provider column is +"no tag from this rule", never an error. R0058 is the +confidence-bump-only meta-rule (no fresh tag emission); the +lifter inspects rule_id and bumps existing tags. + +The v0 :class:`RuleEngine` cannot navigate the intel envelope — +the rules are inert under regex. +""" +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(54, 59)] + + +@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: + fired: set[str] = set() + for row in corpus_loader("intel"): + tags = await precision_engine.evaluate(make_event(row)) + fired.update(tag.rule_id for tag in tags) + assert rule_id not in fired + + +def test_r0058_is_bump_only() -> None: + """R0058's only emit is a zero-confidence sentinel. + + Per Appendix B the aggregate-malicious rule must not emit a fresh + tag — it bumps existing rule confidences. The repository drops + tags below 0.3 confidence, so even if the lifter accidentally + drove the engine fanout the tag would never persist. This test + pins that defense-in-depth property: any future edit pushing the + R0058 emit confidence above 0 would fire here. + """ + compiled = _parse_and_compile( + Path("rules/ttp/R0058.yaml"), RuleState(), + ) + assert all(emit[3] == 0.0 for emit in compiled.emits), ( + "R0058 must keep all emit confidences at 0.0 (bump-only rule)" + ) + + +@pytest.mark.parametrize("rule_id", _RULE_IDS) +@pytest.mark.xfail(strict=True, reason="impl phase E.3.10 (IntelLifter)") +def test_intel_rule_precision(rule_id: str) -> None: + pytest.fail(f"{rule_id}: IntelLifter not yet shipped (E.3.10)")