feat(ttp): E.3.8 R0054-R0058 intel cohort + mark step done

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.
This commit is contained in:
2026-05-01 09:22:48 -04:00
parent dc1867315d
commit b819dfefa3
7 changed files with 204 additions and 1 deletions

View File

@@ -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 (R0001R0030),
behavioral (R0031R0040), email (R0041R0048), canary
(R0049R0053), intel (R0054R0058). Live precision asserts on
R0007R0029 (regex-on-payload-field). Lifter-bound rules
(R0001R0006, R0030, R0031R0058) are inert under the v0 engine
by design — their YAMLs declare technique mappings the
E.3.9E.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

25
rules/ttp/R0054.yaml Normal file
View File

@@ -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

23
rules/ttp/R0055.yaml Normal file
View File

@@ -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

23
rules/ttp/R0056.yaml Normal file
View File

@@ -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

23
rules/ttp/R0057.yaml Normal file
View File

@@ -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

23
rules/ttp/R0058.yaml Normal file
View File

@@ -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

View File

@@ -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)")