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.
This commit is contained in:
2026-05-02 18:08:48 -04:00
parent a31ad82880
commit 75ff0ede1f
3 changed files with 364 additions and 102 deletions

View File

@@ -1,10 +1,16 @@
"""Per-rule unit tests for :class:`IntelLifter` (E.3.10).
"""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, ioc_type) and negatively
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
@@ -52,112 +58,232 @@ def _make_lifter(rule_ids: list[str]) -> IntelLifter:
return lifter
# ── R0054 AbuseIPDB ────────────────────────────────────────────────
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:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 90,
"abuseipdb_categories": [18, 22],
})))
techs = {tag.technique_id for tag in out}
assert "T1110" in techs
assert "T1110" in _techs(out)
def test_abuseipdb_web_attack_emits_t1190() -> None:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 80,
"abuseipdb_categories": [21],
})))
techs = {tag.technique_id for tag in out}
assert "T1190" in techs
assert "T1190" in _techs(out)
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
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 = {tag.technique_id for tag in out}
assert "T1566" in techs
techs = _techs(out)
assert {"T1566", "T1496"} <= 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
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": 50,
"abuseipdb_categories": [11],
})))
techs = {tag.technique_id for tag in out}
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:
lifter = _make_lifter(["R0054"])
out = asyncio.run(lifter.tag(_ev({
out = asyncio.run(_make_lifter(["R0054"]).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})))
out = asyncio.run(_make_lifter(["R0054"]).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({
out = asyncio.run(_make_lifter(["R0054"]).tag(_ev({
"abuseipdb_score": None,
"abuseipdb_categories": [18],
})))
assert out == []
# ── R0055 GreyNoise ────────────────────────────────────────────────
# ── R0055 GreyNoise — corrected v2 mapping ────────────────────────────
def test_greynoise_scanner_emits_t1595() -> None:
lifter = _make_lifter(["R0055"])
out = asyncio.run(lifter.tag(_ev({
out = asyncio.run(_make_lifter(["R0055"]).tag(_ev({
"greynoise_classification": "scanner",
})))
techs = {tag.technique_id for tag in out}
assert "T1595" in techs
assert "T1595" in _techs(out)
def test_greynoise_c2_tag_emits_t1071() -> None:
lifter = _make_lifter(["R0055"])
out = asyncio.run(lifter.tag(_ev({
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"],
})))
techs = {tag.technique_id for tag in out}
assert "T1071" in techs
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:
lifter = _make_lifter(["R0055"])
out = asyncio.run(lifter.tag(_ev({
out = asyncio.run(_make_lifter(["R0055"]).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",
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 == []
@@ -167,47 +293,100 @@ def test_greynoise_unknown_tag_no_emit() -> None:
def test_feodo_listed_emits_both() -> None:
lifter = _make_lifter(["R0056"])
out = asyncio.run(lifter.tag(_ev({
out = asyncio.run(_make_lifter(["R0056"]).tag(_ev({
"feodo_listed": True,
"malware_family": "Emotet",
"feodo_malware_family": "Emotet",
})))
techs = {tag.technique_id for tag in out}
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:
lifter = _make_lifter(["R0056"])
out = asyncio.run(lifter.tag(_ev({"feodo_listed": False})))
out = asyncio.run(_make_lifter(["R0056"]).tag(_ev({"feodo_listed": False})))
assert out == []
def test_feodo_missing_no_emit() -> None:
lifter = _make_lifter(["R0056"])
out = asyncio.run(lifter.tag(_ev({})))
out = asyncio.run(_make_lifter(["R0056"]).tag(_ev({})))
assert out == []
# ── R0057 ThreatFox ────────────────────────────────────────────────
# ── R0057 ThreatFox — corrected v2 mapping ─────────────────────────
def test_threatfox_botnet_cc_emits() -> None:
lifter = _make_lifter(["R0057"])
out = asyncio.run(lifter.tag(_ev({
"ioc_type": "botnet_cc",
"malware_family": "sliver",
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 = {tag.technique_id for tag in out}
assert "T1071" in techs and "T1588" in techs
techs = _techs(out)
assert {"T1071", "T1588"} <= techs
for tag in out:
assert tag.evidence.get("malware_family") == "sliver"
ev_families = tag.evidence.get("malware_families")
if ev_families:
assert "sliver" in ev_families
def test_threatfox_unknown_ioc_no_emit() -> None:
lifter = _make_lifter(["R0057"])
out = asyncio.run(lifter.tag(_ev({"ioc_type": "weird_unknown"})))
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 == []
@@ -215,11 +394,7 @@ def test_threatfox_unknown_ioc_no_emit() -> None:
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({
out = asyncio.run(_make_lifter(["R0058"]).tag(_ev({
"aggregate_verdict": "malicious",
})))
assert out == []
@@ -249,7 +424,6 @@ def test_clipped_intel_rule_caps_confidence() -> None:
})))
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
@@ -257,8 +431,6 @@ def test_clipped_intel_rule_caps_confidence() -> None:
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()