From 0cdf8d90da392e299efa0537742157c22c900d93 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 06:58:12 -0400 Subject: [PATCH] =?UTF-8?q?test(ttp):=20E.2.7=20decoupling=20lint=20?= =?UTF-8?q?=E2=80=94=20TTP=20code=20may=20not=20import=20decnet.intel.*=20?= =?UTF-8?q?providers=20or=20decnet.profiler.keystroke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- development/TTP_TAGGING.md | 2 + tests/ttp/test_decoupling.py | 102 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/ttp/test_decoupling.py diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index 7ecd5f7b..1263d2a6 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -2599,6 +2599,8 @@ until E.3.6). **E.2.7 — Static decoupling lint** (`tests/ttp/test_decoupling.py`) +**Status:** ✅ done. + - Walk every module under `decnet/ttp/` (AST-parse, no runtime import). Assert no module imports from `decnet.intel.{abuseipdb, greynoise, feodo, threatfox}` — only `decnet.web.db.models` is diff --git a/tests/ttp/test_decoupling.py b/tests/ttp/test_decoupling.py new file mode 100644 index 00000000..e2ffe5cb --- /dev/null +++ b/tests/ttp/test_decoupling.py @@ -0,0 +1,102 @@ +"""E.2.7 — Static decoupling lint for ``decnet/ttp/``. + +The "no SPOF" decoupling rule §2 of TTP_TAGGING.md: tagging code +must NEVER import an intel-provider module directly. Intel-derived +data flows through ``decnet.web.db.models`` (the ``AttackerIntel`` +row), not through a function call into +``decnet.intel.{abuseipdb,greynoise,feodo,threatfox}``. A failed +provider produces an empty join, not a crash inside the tagger +worker. + +The same property holds for biometrics: nothing under +``decnet/ttp/`` may reach into ``decnet.profiler.keystroke.*``. +Future biometric ingesters will land in their own subpackages with +the same DB-mediated bridge. + +This is a pure AST walk — no module-level import side effects, no +runtime dependencies. The check runs on every TTP source file +(skipping ``__pycache__`` and dunder names) and surfaces a +``ImportFrom``/``Import`` violation with file path + line number on +failure. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +import decnet.ttp as _ttp_pkg + + +_TTP_ROOT: Path = Path(_ttp_pkg.__file__).resolve().parent + +# Exact module names the tagger MUST NOT import. +_FORBIDDEN_EXACT: frozenset[str] = frozenset({ + "decnet.intel.abuseipdb", + "decnet.intel.greynoise", + "decnet.intel.feodo", + "decnet.intel.threatfox", +}) + +# Forbidden by-prefix: anything under these subpackages is off-limits. +_FORBIDDEN_PREFIXES: tuple[str, ...] = ( + "decnet.profiler.keystroke", +) + + +def _is_forbidden(module_name: str) -> bool: + if module_name in _FORBIDDEN_EXACT: + return True + return any( + module_name == prefix or module_name.startswith(prefix + ".") + for prefix in _FORBIDDEN_PREFIXES + ) + + +def _ttp_python_files() -> list[Path]: + files: list[Path] = [] + for p in _TTP_ROOT.rglob("*.py"): + # Skip caches and any future stubs/shims that might slip in. + if "__pycache__" in p.parts: + continue + files.append(p) + return files + + +def test_at_least_one_ttp_file_visited() -> None: + """Sanity guard: a future refactor that moves the package or + renames the import root must not silently neuter the lint by + walking zero files.""" + files = _ttp_python_files() + assert files, f"no .py files under {_TTP_ROOT} — refactor regressed the lint" + # Spot-check that the lifters and the worker live under the root, + # otherwise the lint scope is wrong. + names = {p.name for p in files} + assert "worker.py" in names + assert "base.py" in names + + +@pytest.mark.parametrize("path", _ttp_python_files(), ids=lambda p: str(p.relative_to(_TTP_ROOT))) +def test_no_forbidden_imports(path: Path) -> None: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + violations: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if _is_forbidden(alias.name): + violations.append( + f"{path}:{node.lineno} import {alias.name}" + ) + elif isinstance(node, ast.ImportFrom): + mod = node.module or "" + if _is_forbidden(mod): + violations.append( + f"{path}:{node.lineno} from {mod} import …" + ) + assert not violations, ( + "decoupling rule §2 violated — TTP code must reach intel " + "data via decnet.web.db.models only:\n " + + "\n ".join(violations) + )