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

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