Files
DECNET/tests/ttp/test_intel_lifter.py
anti 75ff0ede1f fix(ttp): correct intel_lifter mappings + repoint ThreatFox to threat_type
Three bug classes uncovered by the 2026-05-02 ship-time audit:

* AbuseIPDB code/name mismatch in v1: cat 10 was treated as DDoS (it's
  Web Spam — DDoS is cat 4, intentionally unmapped per A.10) and cat 17
  as VPN IP (it's Spoofing — VPN IP is cat 13). Both typos mirrored in
  code AND the design doc Appendix A.10. Code now matches the AbuseIPDB
  taxonomy exactly; cat 17 retargets to T1566 (email-spoofing as a
  phishing precursor), and cats 7 (Phishing) and 16 (SQL Injection)
  pick up T1566 / T1190 emissions that v1 didn't cover.

* ThreatFox dispatch keyed on `ioc_type` in v1, but `ioc_type` is the
  indicator format (url / domain / hash variants) and carries no ATT&CK
  signal. The canonical taxonomy field per ThreatFox's API is
  `threat_type` (botnet_cc / payload_delivery / payload / cc_skimming).
  Repoint dispatch through the new `threatfox_threat_types` payload
  field; `ioc_type` rides as evidence only. Also adds the missing
  cc_skimming -> T1056 (Input Capture) mapping and registers T1056 in
  attack_catalog.py.

* GreyNoise bare-malicious lane: a `classification == "malicious"` row
  with no recognised tag used to emit nothing. Now lights T1071 at a
  half multiplier, suppressed when a tag already fires T1071 to avoid
  double-stamping at conflicting confidence levels.
2026-05-02 18:08:48 -04:00

464 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Per-rule unit tests for :class:`IntelLifter` (E.3.10 + 2026-05-02 audit).
Per Appendix A.10 each provider's mapping is exercised positively with
realistic payload shapes (categories, tags, threat_types) 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.
The 2026-05-02 ship-time audit found a class of cascade bugs where
``_emit_filtered`` silently dropped predicate decisions whose
``technique_id`` was missing from the rule YAML's ``emits`` list. The
test suite below exercises every previously-dropped technique end-to-
end so a regression of the same kind shows up immediately.
"""
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
def _techs(out: list[Any]) -> set[str]:
return {tag.technique_id for tag in out}
# ── R0054 AbuseIPDB — corrected v2 mapping ────────────────────────────
def test_abuseipdb_brute_force_category_emits_t1110() -> None:
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 90,
"abuseipdb_categories": [18, 22],
})))
assert "T1110" in _techs(out)
def test_abuseipdb_web_attack_emits_t1190() -> None:
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [21],
})))
assert "T1190" in _techs(out)
def test_abuseipdb_email_spam_high_score_includes_t1566_and_t1496() -> None:
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 90,
"abuseipdb_categories": [11],
})))
techs = _techs(out)
assert {"T1566", "T1496"} <= techs
def test_abuseipdb_email_spam_low_score_excludes_t1566() -> None:
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 50,
"abuseipdb_categories": [11],
})))
techs = _techs(out)
assert "T1566" not in techs
assert "T1496" in techs # ungated, still fires
def test_abuseipdb_port_scan_emits_t1046_and_t1595() -> None:
"""Cat 14 → T1046 + T1595. Both were silently dropped pre-v2."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [14],
})))
assert {"T1046", "T1595"} <= _techs(out)
def test_abuseipdb_exploited_host_emits_t1078() -> None:
"""Cat 20 → T1078. Silently dropped pre-v2."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [20],
})))
assert "T1078" in _techs(out)
def test_abuseipdb_open_proxy_emits_t1090() -> None:
"""Cat 9 → T1090. Silently dropped pre-v2."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [9],
})))
assert "T1090" in _techs(out)
def test_abuseipdb_vpn_uses_correct_cat_13() -> None:
"""Audit fix: cat 13 (VPN IP), NOT cat 17 — that was the v1 typo."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [13],
})))
assert "T1090" in _techs(out)
def test_abuseipdb_cat_17_now_emits_t1566_not_t1090() -> None:
"""Audit fix: cat 17 is Spoofing, not VPN. Now → T1566 (phishing)."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [17],
})))
techs = _techs(out)
assert "T1566" in techs
assert "T1090" not in techs
def test_abuseipdb_phishing_cat_7_emits_t1566() -> None:
"""New mapping: cat 7 (Phishing) → T1566."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [7],
})))
assert "T1566" in _techs(out)
def test_abuseipdb_sql_injection_cat_16_emits_t1190() -> None:
"""New mapping: cat 16 (SQL Injection) → T1190."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [16],
})))
assert "T1190" in _techs(out)
def test_abuseipdb_cat_10_no_longer_emits_ddos() -> None:
"""Audit fix: cat 10 is Web Spam, not DDoS. Used to wrongly fire T1498.
Cat 10 is intentionally unmapped in v2 — no clean ATT&CK fit at IP layer.
"""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 95,
"abuseipdb_categories": [10],
})))
assert "T1498" not in _techs(out)
def test_abuseipdb_cat_4_remains_unmapped() -> None:
"""Per A.10: cat 4 (real DDoS) is intentionally dropped — too muddy."""
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 95,
"abuseipdb_categories": [4],
})))
assert _techs(out) == set()
def test_abuseipdb_confidence_scaled_by_score() -> None:
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 50,
"abuseipdb_categories": [18],
})))
assert out
for tag in out:
if tag.technique_id == "T1110":
assert tag.confidence == pytest.approx(0.35)
def test_abuseipdb_no_categories_no_emit() -> None:
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 95,
})))
assert out == []
def test_abuseipdb_score_none_no_emit() -> None:
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": None,
"abuseipdb_categories": [18],
})))
assert out == []
# ── R0055 GreyNoise — corrected v2 mapping ────────────────────────────
def test_greynoise_scanner_emits_t1595() -> None:
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "scanner",
})))
assert "T1595" in _techs(out)
def test_greynoise_c2_tag_emits_both_t1071_and_t1588() -> None:
"""Audit fix: T1588 used to be silently dropped. Now both fire."""
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "malicious",
"greynoise_tags": ["cobalt_strike"],
})))
assert {"T1071", "T1588"} <= _techs(out)
def test_greynoise_tor_exit_emits_t1090() -> None:
"""Audit fix: tor_exit_node → T1090 silently dropped pre-v2."""
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "malicious",
"greynoise_tags": ["tor_exit_node"],
})))
assert "T1090" in _techs(out)
def test_greynoise_ssh_bruteforcer_emits_t1110() -> None:
"""Audit fix: ssh_bruteforcer → T1110 silently dropped pre-v2."""
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "malicious",
"greynoise_tags": ["ssh_bruteforcer"],
})))
assert "T1110" in _techs(out)
def test_greynoise_bare_malicious_emits_t1071_at_half() -> None:
"""Audit gap: bare malicious classification used to emit nothing."""
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "malicious",
"greynoise_tags": [],
})))
techs_by_id = {tag.technique_id: tag for tag in out}
assert "T1071" in techs_by_id
# R0055 base T1071 conf is 0.7; bare-malicious multiplier is 0.5 →
# 0.35. Tags would have fired at 1.0× (0.7).
assert techs_by_id["T1071"].confidence == pytest.approx(0.35)
def test_greynoise_bare_malicious_does_not_fire_when_specific_tag_present() -> None:
"""Bare-malicious lane is suppressed when a tag already fires T1071
so we don't double-stamp at conflicting confidence levels."""
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "malicious",
"greynoise_tags": ["cobalt_strike"],
})))
t1071_tags = [t for t in out if t.technique_id == "T1071"]
assert len(t1071_tags) == 1
assert t1071_tags[0].confidence == pytest.approx(0.7) # tag rate, not 0.35
def test_greynoise_benign_no_emit() -> None:
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "benign",
"greynoise_tags": [],
})))
assert out == []
def test_greynoise_unknown_tag_with_unknown_classification_no_emit() -> None:
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "unknown",
"greynoise_tags": ["random_unmapped"],
})))
assert out == []
# ── R0056 Feodo ────────────────────────────────────────────────────
def test_feodo_listed_emits_both() -> None:
out = asyncio.run(_make_lifter(["R0056"]).tag(_ev({
"feodo_listed": True,
"feodo_malware_family": "Emotet",
})))
techs = _techs(out)
assert techs == {"T1071", "T1588"}
for tag in out:
assert tag.evidence.get("malware_family") == "Emotet"
def test_feodo_legacy_malware_family_field_still_works() -> None:
"""Tolerate the older payload shape (`malware_family` not prefixed)."""
out = asyncio.run(_make_lifter(["R0056"]).tag(_ev({
"feodo_listed": True,
"malware_family": "Dridex",
})))
for tag in out:
assert tag.evidence.get("malware_family") == "Dridex"
def test_feodo_unlisted_no_emit() -> None:
out = asyncio.run(_make_lifter(["R0056"]).tag(_ev({"feodo_listed": False})))
assert out == []
def test_feodo_missing_no_emit() -> None:
out = asyncio.run(_make_lifter(["R0056"]).tag(_ev({})))
assert out == []
# ── R0057 ThreatFox — corrected v2 mapping ─────────────────────────
def test_threatfox_botnet_cc_threat_type_emits_t1071_and_t1588() -> None:
"""Audit fix: ThreatFox keys on threat_type now (was ioc_type). And
T1588 used to be silently dropped despite being in the mapping."""
out = asyncio.run(_make_lifter(["R0057"]).tag(_ev({
"threatfox_threat_types": ["botnet_cc"],
"threatfox_malware_families": ["sliver"],
})))
techs = _techs(out)
assert {"T1071", "T1588"} <= techs
for tag in out:
ev_families = tag.evidence.get("malware_families")
if ev_families:
assert "sliver" in ev_families
def test_threatfox_payload_delivery_emits_t1105_and_t1588() -> None:
"""Audit fix: payload_delivery → T1105 silently dropped pre-v2."""
out = asyncio.run(_make_lifter(["R0057"]).tag(_ev({
"threatfox_threat_types": ["payload_delivery"],
})))
assert {"T1105", "T1588"} <= _techs(out)
def test_threatfox_payload_emits_t1588_only() -> None:
"""New mapping: threat_type=payload (hash-only IOC) → T1588."""
out = asyncio.run(_make_lifter(["R0057"]).tag(_ev({
"threatfox_threat_types": ["payload"],
})))
assert "T1588" in _techs(out)
def test_threatfox_cc_skimming_emits_t1056() -> None:
"""New mapping: cc_skimming → T1056 (Input Capture)."""
out = asyncio.run(_make_lifter(["R0057"]).tag(_ev({
"threatfox_threat_types": ["cc_skimming"],
})))
assert "T1056" in _techs(out)
def test_threatfox_legacy_threat_type_singular_still_works() -> None:
"""Tolerate the legacy/test payload shape with a singular field."""
out = asyncio.run(_make_lifter(["R0057"]).tag(_ev({
"threat_type": "botnet_cc",
})))
assert "T1071" in _techs(out)
def test_threatfox_unknown_threat_type_no_emit() -> None:
out = asyncio.run(_make_lifter(["R0057"]).tag(_ev({
"threatfox_threat_types": ["mystery_type"],
})))
assert out == []
def test_threatfox_ioc_type_alone_no_longer_drives_techniques() -> None:
"""Audit fix: ioc_type is the indicator format (url/domain/hash) and
carries no ATT&CK signal. v1 mistakenly keyed on it."""
out = asyncio.run(_make_lifter(["R0057"]).tag(_ev({
"threatfox_ioc_types": ["url", "domain"],
# threat_types intentionally absent
})))
assert out == []
# ── R0058 Aggregate bump (no-op in v0) ─────────────────────────────
def test_aggregate_bump_is_inert_in_v0() -> None:
out = asyncio.run(_make_lifter(["R0058"]).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:
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:
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