feat(ttp): E.3.10 IntelLifter (R0054-R0058)

Per-provider verdict translator for AbuseIPDB, GreyNoise, Feodo Tracker,
and ThreatFox per Appendix A.10. Each rule's predicate inspects payload
fields produced by the enrich worker (no DB I/O, no decnet.intel.*
imports — E.2.7 decoupling guard preserved). AbuseIPDB confidence is
scaled by abuse_confidence_score / 100; categories drive per-technique
fan-out. R0058 aggregate-bump is a no-op in v0 (cross-tag bump deferred
to E.3.14 worker bootstrap).

Per-provider null tolerance is the steady state — a missing provider
column produces zero tags from that rule, never an error.

Tests:
- tests/ttp/test_intel_lifter.py — per-provider positive + negative +
  state modulation + decoupling source-import guard.
- tests/ttp/rule_precision/test_intel_rules.py — xfail flipped, real
  precision driven over seed_intel.jsonl (R0054-R0057 H-band ≥95%;
  R0058 skipped as bump-only).
- tests/ttp/test_lifter_absence.py — IntelLifter all-populated test
  flipped from xfail-strict to real assertion with realistic payload.
- tests/ttp/test_lifters.py — partial-null xfail flipped to real
  assertion.
This commit is contained in:
2026-05-01 20:23:42 -04:00
parent eff3e4bce7
commit 7865e71aa9
7 changed files with 653 additions and 34 deletions

View File

@@ -1,2 +1,8 @@
{"source_kind": "intel", "payload": {"verdict": "malicious", "provider": "abuseipdb", "categories": [18, 22]}, "expected_rule_ids": ["R0054"], "label": "abuseipdb_brute"}
{"source_kind": "intel", "payload": {"verdict": "benign", "provider": "greynoise", "tags": []}, "expected_rule_ids": [], "label": "negative_benign"}
{"source_kind": "intel", "payload": {"abuseipdb_score": 95, "abuseipdb_categories": [18, 22], "provider": "abuseipdb"}, "expected_rule_ids": ["R0054"], "label": "abuseipdb_brute"}
{"source_kind": "intel", "payload": {"greynoise_classification": "scanner"}, "expected_rule_ids": ["R0055"], "label": "greynoise_scanner"}
{"source_kind": "intel", "payload": {"greynoise_classification": "malicious", "greynoise_tags": ["cobalt_strike"]}, "expected_rule_ids": ["R0055"], "label": "greynoise_c2_tag"}
{"source_kind": "intel", "payload": {"feodo_listed": true, "malware_family": "Emotet"}, "expected_rule_ids": ["R0056"], "label": "feodo_emotet"}
{"source_kind": "intel", "payload": {"ioc_type": "botnet_cc", "malware_family": "sliver"}, "expected_rule_ids": ["R0057"], "label": "threatfox_botnet_cc"}
{"source_kind": "intel", "payload": {"ioc_type": "payload_delivery", "malware_family": "asyncrat"}, "expected_rule_ids": ["R0057"], "label": "threatfox_payload"}
{"source_kind": "intel", "payload": {"verdict": "benign", "provider": "greynoise", "greynoise_classification": "benign", "greynoise_tags": []}, "expected_rule_ids": [], "label": "negative_benign"}
{"source_kind": "intel", "payload": {}, "expected_rule_ids": [], "label": "negative_empty"}

View File

@@ -67,7 +67,45 @@ def test_r0058_is_bump_only() -> None:
)
def _build_lifter() -> "IntelLifter":
from decnet.ttp.impl.intel_lifter import IntelLifter
from tests.ttp._stub_store import StubRuleStore
rules = [
_parse_and_compile(Path("rules/ttp") / f"{rid}.yaml", RuleState())
for rid in _RULE_IDS
]
lifter = IntelLifter(StubRuleStore(compiled=rules))
for rule in rules:
lifter._index.install(rule)
return lifter
@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)")
def test_intel_rule_precision(
rule_id: str,
corpus_loader: CohortLoader,
) -> None:
"""E.3.10: drive IntelLifter over the labelled corpus and assert
per-rule precision. R0058 (bump-only) is excluded — it intentionally
never emits a tag, so vacuous precision is irrelevant.
"""
import asyncio
from tests.ttp.rule_precision.conftest import precision_for
if rule_id == "R0058":
pytest.skip("R0058 is bump-only; no precision target")
rows = corpus_loader("intel")
if not rows:
pytest.skip("no intel corpus available")
lifter = _build_lifter()
fired: dict[str, list[str]] = {}
for row in rows:
tags = asyncio.run(lifter.tag(make_event(row)))
fired[row.label] = [tag.rule_id for tag in tags]
precision, _tp, _fp = precision_for(rule_id, rows, fired)
# R0054/R0055/R0056/R0057 are H-band per Appendix C → ≥95%.
assert precision >= 0.95, (
f"{rule_id} precision {precision:.2f} < 0.95 on intel corpus"
)

View File

@@ -0,0 +1,291 @@
"""Per-rule unit tests for :class:`IntelLifter` (E.3.10).
Per Appendix A.10 each provider's mapping is exercised positively with
realistic payload shapes (categories, tags, ioc_type) and negatively
with null / missing signals. The lifter must NEVER import from
``decnet.intel.*``; the static guard at E.2.7 enforces that — these
tests are the behavioral counterpart.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
import pytest
from decnet.ttp.base import TaggerEvent
from decnet.ttp.impl.intel_lifter import IntelLifter
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleState
from decnet.ttp.store.impl.filesystem import _parse_and_compile
from tests.ttp._stub_store import StubRuleStore
_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp"
def _compile(rule_id: str, state: RuleState | None = None) -> CompiledRule:
return _parse_and_compile(
_RULES_DIR / f"{rule_id}.yaml", state or RuleState(),
)
def _ev(payload: dict[str, Any]) -> TaggerEvent:
return TaggerEvent(
source_kind="intel",
source_id="src-intel",
attacker_uuid="att1",
identity_uuid=None,
session_id=None,
decky_id=None,
payload=payload,
)
def _make_lifter(rule_ids: list[str]) -> IntelLifter:
rules = [_compile(rid) for rid in rule_ids]
lifter = IntelLifter(StubRuleStore(compiled=rules))
for rule in rules:
lifter._index.install(rule)
return lifter
# ── R0054 AbuseIPDB ────────────────────────────────────────────────
def test_abuseipdb_brute_force_category_emits_t1110() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": 90,
"abuseipdb_categories": [18, 22],
})))
techs = {tag.technique_id for tag in out}
assert "T1110" in techs
def test_abuseipdb_web_attack_emits_t1190() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [21],
})))
techs = {tag.technique_id for tag in out}
assert "T1190" in techs
def test_abuseipdb_email_spam_high_score_includes_t1566() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": 90, # gated >=80
"abuseipdb_categories": [11],
})))
techs = {tag.technique_id for tag in out}
assert "T1566" in techs
def test_abuseipdb_email_spam_low_score_excludes_t1566() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": 50, # below the T1566 gate
"abuseipdb_categories": [11],
})))
techs = {tag.technique_id for tag in out}
assert "T1566" not in techs
def test_abuseipdb_confidence_scaled_by_score() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": 50,
"abuseipdb_categories": [18],
})))
assert out
# Base for T1110 in R0054 YAML is 0.7 → 0.7 * 0.5 = 0.35.
for tag in out:
if tag.technique_id == "T1110":
assert tag.confidence == pytest.approx(0.35)
def test_abuseipdb_no_categories_no_emit() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({"abuseipdb_score": 95})))
assert out == []
def test_abuseipdb_score_none_no_emit() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": None,
"abuseipdb_categories": [18],
})))
assert out == []
# ── R0055 GreyNoise ────────────────────────────────────────────────
def test_greynoise_scanner_emits_t1595() -> None:
lifter = _make_lifter(["R0055"])
out = asyncio.run(lifter.tag(_ev({
"greynoise_classification": "scanner",
})))
techs = {tag.technique_id for tag in out}
assert "T1595" in techs
def test_greynoise_c2_tag_emits_t1071() -> None:
lifter = _make_lifter(["R0055"])
out = asyncio.run(lifter.tag(_ev({
"greynoise_classification": "malicious",
"greynoise_tags": ["cobalt_strike"],
})))
techs = {tag.technique_id for tag in out}
assert "T1071" in techs
def test_greynoise_benign_no_emit() -> None:
lifter = _make_lifter(["R0055"])
out = asyncio.run(lifter.tag(_ev({
"greynoise_classification": "benign",
"greynoise_tags": [],
})))
assert out == []
def test_greynoise_unknown_tag_no_emit() -> None:
lifter = _make_lifter(["R0055"])
out = asyncio.run(lifter.tag(_ev({
"greynoise_classification": "malicious",
"greynoise_tags": ["random_unmapped"],
})))
assert out == []
# ── R0056 Feodo ────────────────────────────────────────────────────
def test_feodo_listed_emits_both() -> None:
lifter = _make_lifter(["R0056"])
out = asyncio.run(lifter.tag(_ev({
"feodo_listed": True,
"malware_family": "Emotet",
})))
techs = {tag.technique_id for tag in out}
assert techs == {"T1071", "T1588"}
for tag in out:
assert tag.evidence.get("malware_family") == "Emotet"
def test_feodo_unlisted_no_emit() -> None:
lifter = _make_lifter(["R0056"])
out = asyncio.run(lifter.tag(_ev({"feodo_listed": False})))
assert out == []
def test_feodo_missing_no_emit() -> None:
lifter = _make_lifter(["R0056"])
out = asyncio.run(lifter.tag(_ev({})))
assert out == []
# ── R0057 ThreatFox ────────────────────────────────────────────────
def test_threatfox_botnet_cc_emits() -> None:
lifter = _make_lifter(["R0057"])
out = asyncio.run(lifter.tag(_ev({
"ioc_type": "botnet_cc",
"malware_family": "sliver",
})))
techs = {tag.technique_id for tag in out}
assert "T1071" in techs and "T1588" in techs
for tag in out:
assert tag.evidence.get("malware_family") == "sliver"
def test_threatfox_unknown_ioc_no_emit() -> None:
lifter = _make_lifter(["R0057"])
out = asyncio.run(lifter.tag(_ev({"ioc_type": "weird_unknown"})))
assert out == []
# ── R0058 Aggregate bump (no-op in v0) ─────────────────────────────
def test_aggregate_bump_is_inert_in_v0() -> None:
"""R0058 is a bump-only meta-rule; the v0 lifter cannot bump
cross-tag confidences from a single TaggerEvent. Stays no-op
until E.3.14 worker bootstrap can plumb the cross-tag write."""
lifter = _make_lifter(["R0058"])
out = asyncio.run(lifter.tag(_ev({
"aggregate_verdict": "malicious",
})))
assert out == []
# ── State modulation ───────────────────────────────────────────────
def test_disabled_intel_rule_no_emit() -> None:
rule = _compile("R0054", RuleState(state="disabled"))
lifter = IntelLifter(StubRuleStore())
lifter._index.install(rule)
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": 95,
"abuseipdb_categories": [18],
})))
assert out == []
def test_clipped_intel_rule_caps_confidence() -> None:
rule = _compile("R0054", RuleState(state="clipped", confidence_max=0.5))
lifter = IntelLifter(StubRuleStore())
lifter._index.install(rule)
out = asyncio.run(lifter.tag(_ev({
"abuseipdb_score": 100,
"abuseipdb_categories": [18],
})))
assert out
for tag in out:
# Base T1110 conf 0.7 × score 1.0 × ceiling 0.5 = 0.35
assert tag.confidence <= 0.35 + 1e-6
# ── Decoupling guard (behavioral counterpart of E.2.7 static check) ─
def test_module_has_no_intel_imports() -> None:
"""IntelLifter must reach AttackerIntel data only via the upstream
payload — never by importing from decnet.intel.*."""
import decnet.ttp.impl.intel_lifter as mod # noqa: PLC0415
src = Path(mod.__file__ or "").read_text()
assert "from decnet.intel" not in src
assert "import decnet.intel" not in src
# ── Tolerance / no-error logging on absent payload ─────────────────
def test_empty_payload_returns_empty_no_errors(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
lifter = _make_lifter(["R0054", "R0055", "R0056", "R0057", "R0058"])
out = asyncio.run(lifter.tag(_ev({})))
assert out == []
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
# ── Ownership ──────────────────────────────────────────────────────
def test_owns_only_intel_prefix() -> None:
behavioral = _compile("R0031")
intel = _compile("R0054")
lifter = IntelLifter(StubRuleStore(compiled=[behavioral, intel]))
asyncio.run(lifter._index.hydrate_from(
lifter._store, predicate=lifter._owns, # type: ignore[arg-type]
))
assert lifter._index.get("R0054") is not None
assert lifter._index.get("R0031") is None

View File

@@ -42,7 +42,7 @@ def _make_lifter(cls: type[TolerantTagger]) -> TolerantTagger:
Implemented lifters (E.3.9E.3.12) take a :class:`RuleStore`; the
still-empty IdentityLifter / CredentialLifter (E.3.13) take no args.
"""
if cls is BehavioralLifter:
if cls in {BehavioralLifter, IntelLifter}:
return cls(StubRuleStore()) # type: ignore[call-arg]
return cls()
@@ -134,7 +134,7 @@ def test_intel_lifter_partial_null_returns_no_error(
) -> None:
caplog.clear()
caplog.set_level(logging.DEBUG)
out = asyncio.run(IntelLifter().tag(_ev("intel", payload)))
out = asyncio.run(IntelLifter(StubRuleStore()).tag(_ev("intel", payload)))
# Every partial-null shape produces zero tags today and zero
# ERROR records — the contract this commit pins. (When E.3.6
# ships, only the "all populated" shape graduates to non-empty;
@@ -143,18 +143,30 @@ def test_intel_lifter_partial_null_returns_no_error(
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
@pytest.mark.xfail(
strict=True,
reason="impl phase E.3.6: intel_lifter does not yet emit tags",
)
def test_intel_lifter_all_populated_emits_tags() -> None:
"""When AbuseIPDB AND GreyNoise both return verdicts, intel_lifter
must emit at least one tag. Strict-xfail today; flips when impl
lands."""
"""E.3.10: when a populated AbuseIPDB row carries actionable
categories AND GreyNoise classifies as scanner, the lifter emits
at least one tag. Real rule pack loaded from disk so the test
catches a regression in either the YAML or the predicate.
"""
from pathlib import Path
from decnet.ttp.store.base import RuleState
from decnet.ttp.store.impl.filesystem import _parse_and_compile
rules_dir = Path("rules/ttp")
rules = [
_parse_and_compile(rules_dir / f"R{n:04d}.yaml", RuleState())
for n in (54, 55, 56, 57, 58)
]
lifter = IntelLifter(StubRuleStore(compiled=rules))
for rule in rules:
lifter._index.install(rule)
payload = {
"attacker_uuid": "att1",
"abuseipdb_score": 95,
"greynoise_classification": "malicious",
"abuseipdb_categories": [18, 22],
"greynoise_classification": "scanner",
}
out = asyncio.run(IntelLifter().tag(_ev("intel", payload)))
out = asyncio.run(lifter.tag(_ev("intel", payload)))
assert len(out) >= 1

View File

@@ -24,7 +24,7 @@ from tests.ttp._stub_store import StubRuleStore
def _instantiate(cls: type[TolerantTagger]) -> TolerantTagger:
if cls is BehavioralLifter:
if cls in {BehavioralLifter, IntelLifter}:
return cls(StubRuleStore()) # type: ignore[call-arg]
return cls()
@@ -87,9 +87,12 @@ def test_lifter_instantiable(cls):
# ── E.2.6 deferred absence-tolerance behavior ──────────────────────
@pytest.mark.xfail(strict=True, reason="impl phase E.3 — IntelLifter null patterns")
def test_e26_intel_lifter_partial_provider_nulls():
raise AssertionError("not yet implemented")
"""E.3.10: with no actionable per-provider signal (e.g. score set
but categories absent), IntelLifter returns []. No errors."""
lifter = IntelLifter(StubRuleStore())
out = asyncio.run(lifter.tag(_ev("intel")))
assert out == []
def test_e26_behavioral_lifter_no_attacker_behavior_row():