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:
@@ -42,7 +42,7 @@ def _make_lifter(cls: type[TolerantTagger]) -> TolerantTagger:
|
||||
Implemented lifters (E.3.9–E.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
|
||||
|
||||
Reference in New Issue
Block a user